开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

使用前端技术实现静态图片局部流动效果 🌊

发表于 2022-08-03

声明:本文已经授权【稀土掘金技术社区】公众号独家原创发布!

我正在参加「创意开发 投稿大赛」详情请看:掘金创意开发大赛来了!

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

背景

如果你有玩过 🎮 《王者荣耀》、《阴阳师》 等手游,一定注意到过它的启动动画、皮肤立绘卡片等场景,经常采用静态底图加局部液态流动效果的简单动画,这些流动动画可能出现在缓缓流动的水流 🌊、迎风飘动的旗帜 🎏、游戏角色衣袖 🧜‍♀️、随着时间缓动的云、雨、雾天气效果 ⛅ 等。这种过渡效果不仅节省了开发全量动画的成本,而且使得游戏画面更加热血、冒险、奥德赛、高级,也更加容易吸引玩家氪金 💰。

本文使用前端开发技术,结合 SVG 和 CSS 来实现类似的液化流动效果。本文包含的知识点主要包括:mask-image 遮罩、feTurbulence 和 feDisplacementMap 滤镜、filter 属性、canvas 绘制方法、TimelineMax 动画以及input[type=file] 本地图片资源加载等。

效果

先来看看实现效果,下面几个示例以及 👆 文章 Banner 图都是应用了由本文内容生成的液态流动动画效果。由于GIF 图压缩比较严重,动画效果看起来不是很流畅 🙃,大家不妨通过以下演示页面链接,亲自体验一下效果,生成自己的 传说、典藏 皮肤立绘吧 😅。

👁‍🗨 在线体验:dragonir.github.io/paint-heat-…

🌀 雾气扩散 塞尔达传说:旷野之息

💃 衣袖飘动 貂蝉:猫影幻舞

🌅 湖光波动

🔠 文字液化

📌 ps:体验页面部署在 Gitpage 上传图片功能不是真正上传到服务器,而是只会加载到浏览器本地,页面不会获取任何信息,大家可以放心体验,不用担心隐私泄漏问题。

码上掘金

实现

页面主要由 2 部分构成,顶部用于加载图片 ,并且可以通过按住 🖱 鼠标划动的方式绘制热点路径,给图片添加流动效果;底部是控制区域,点击按钮 🔘 清除画布,可以清除绘制的流动动画效果、点击按钮 🔘 切换图片可以加载本地的图片。

📌 注意,还有一个隐形的功能,当你绘制完成时,可以点击 🖱 鼠标右键,然后选择保存图片,保存的这张图片就是我们绘制流体动画路径的热点图,利用这张热点图,使用本文的 CSS 知识,就能把静态图片转化成动态图啦!

HTML 页面结构

#sketch 元素主要是用于绘制和加载流动效果热点图的画板;#button_container 是页面底部的按钮控制区域;svg 元素用于利用其 filter 滤镜实现液态流动动画效果,包括 feTurbulence 和 feDisplacementMap 滤镜。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
html复制代码<main id="sketch">
<canvas id="canvas" data-img=""></canvas>
<div class="mask">
<div id="maskInner" class="mask-inner"></div>
</div>
</main>
<section class="button_container">
<button class="button">清除画布</button>
<button class="button"><input class="input" type="file" id="upload">上传图片</button>
</section>
<svg>
<filter id="heat" filterUnits="objectBoundingBox" x="0" y="0" width="100%" height="100%">
<feTurbulence id="heatturb" type="fractalNoise" numOctaves="1" seed="2" />
<feDisplacementMap xChannelSelector="G" yChannelSelector="B" scale="22" in="SourceGraphic" />
</filter>
</svg>

💡 feTurbulence 和 feDisplacementMap

  • feTurbulence:滤镜利用 Perlin 噪声函数创建了一个图像,利用它可以实现人造纹理比如说云纹、大理石纹等模拟滤镜效果。
  • feDisplacementMap:映射置换滤镜,该滤镜用来自图像中从 in2 到空间的像素值置换图像从 in 到空间的像素值。即它可以改变元素和图形的像素位置,通过遍历原图形的所有像素点,feDisplacementMap 重新映射替换一个新的位置,形成一个新的图形。该滤镜在业界的主流应用是对图形进行形变,扭曲,液化。

CSS 样式

接着看看样式的实现,main 元素作为主容器并将主图案作为背景图片;canvas 作为画布占据 100% 的空间位置;.mask 和 .mask-inner 用于生成如下图所示热点路径与背景图相溶的效果,这种效果是借助 mask-image 实现的。最后,为了生成动态流动效果,.mask-inner 通过 filter: url(#heat) 将前面生成的 svg 作为滤镜来源,后续即将在 JavaScript 中通过不间断修改 svg 滤镜的属性,来生成液态流动动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
css复制代码main {
position: relative;
background-image: url('bg.jpg');
background-size: cover;
background-position: 100% 50%;
}
canvas {
opacity: 0;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.mask {
display: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
mask-mode: luminance;
mask-size: 100% 100%;
backdrop-filter: hard-light;
mask-image: url('mask.png');
}
.mask-inner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url('bg.jpg') 0% 0% repeat;
background-size: cover;
background-position: 100% 50%;
filter: url(#heat);
mask-image: url('mask.png')
}

💡 mask-image

mask-image CSS 属性用于设置元素上遮罩层的图像。

语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
css复制代码// 默认值,透明的黑色图像层,也就是没有遮罩层。
mask-image: none;
// <mask-source><mask>或CSS图像的url的值
mask-image: url(masks.svg#mask1);
// <image> 图片作为遮罩层
mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent);
mask-image: image(url(mask.png), skyblue);
// 多个值
mask-image: image(url(mask.png), skyblue), linear-gradient(rgba(0, 0, 0, 1.0), transparent);
// 全局值
mask-image: inherit;
mask-image: initial;
mask-image: unset;

兼容性:

⚡ 此功能某些浏览器尚在开发中,需要使用浏览器前缀以兼容不同浏览器。

JavaScript 方法

① 绘制热点图

监听鼠标移动和点击事件,在 canvas 上绘制波动路径热点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
js复制代码var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');
var sketch = document.getElementById('sketch');
var sketchStyle = window.getComputedStyle(sketch);
var mouse = { x: 0, y: 0 };

canvas.width = parseInt(sketchStyle.getPropertyValue('width'));
canvas.height = parseInt(sketchStyle.getPropertyValue('height'));
canvas.addEventListener('mousemove', e => {
mouse.x = e.pageX - canvas.getBoundingClientRect().left;
mouse.y = e.pageY - canvas.getBoundingClientRect().top;
}, false);

ctx.lineWidth = 40;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = 'black';

canvas.addEventListener('mousedown', () => {
ctx.beginPath();
ctx.moveTo(mouse.x, mouse.y);
canvas.addEventListener('mousemove', onPaint, false);
}, false);

canvas.addEventListener('mouseup', () => {
canvas.removeEventListener('mousemove', onPaint, false);
}, false);

var onPaint = () => {
ctx.lineTo(mouse.x, mouse.y);
ctx.stroke();
var url = canvas.toDataURL();
document.querySelectorAll('div').forEach(item => {
item.style.cssText += `
display: initial;
-webkit-mask-image: url(${url});
mask-image: url(${url});
`;
});
};

绘制完成后,可以在页面中右键保存生成的波动路径热点图,直接将绘制满意的热点图放到 CSS 中,就能给喜欢的图片添加局部波动效果了,下面这张图片就是本示例页面使用的波动的热点路径图。

② 生成动画

为了生成实时更新的波动效果,本文使用了 TweenMax 来通过改变 feTurbulence 的 baseFrequency 属性值来实现,使用其他动画库或使用 requestAnimationFrame 也是可以实现相同的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js复制代码feTurb = document.querySelector('#heatturb');
var timeline = new TimelineMax({
repeat: -1,
yoyo: true
}),
timeline.add(
new TweenMax.to(feTurb, 8, {
onUpdate: () => {
var bfX = this.progress() * 0.01 + 0.025,
bfY = this.progress() * 0.003 + 0.01,
bfStr = bfX.toString() + ' ' + bfY.toString();
feTurb.setAttribute('baseFrequency', bfStr);
}
}),
0);

③ 清除画布

点击清除画布按钮,可以清空已经绘制的波动路径,主要是通过清除页面元素 mask-image 的属性值以及清 canvas 画布来实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
js复制代码function clear() {
document.querySelectorAll('div').forEach(item => {
item.style.cssText += `
display: none;
-webkit-mask-image: none;
mask-image: none;
`;
});
}

document.querySelectorAll('.button').forEach(item => {
item.addEventListener('click', () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
clear();
})
});

④ 切换图片

点击切换图片,可以加载本地的一张图片作为绘制底图,该功能是通过 input[type=file] 来实现图片资源的获取,然后通过修改 CSS 将它设置成新的画布背景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js复制代码document.getElementById('upload').onchange = function () {
var imageFile = this.files[0];
var newImg = window.URL.createObjectURL(imageFile);
clear();
document.getElementById('sketch').style.cssText += `
background: url(${newImg});
background-size: cover;
background-position: center;
`;
document.getElementById('maskInner').style.cssText += `
background: url(${newImg});
background-size: cover;
background-position: center;
`;
};

到这里,全部功能都实现完毕了,大家赶快制作一张自己喜欢的 史诗皮肤 或 奥德赛小游戏 的启动页面吧 🤣。

📥 源码地址:github.com/dragonir/pa…

总结

本文包含的新知识点主要包括:

  • mask-image 遮罩元素
  • feTurbulence 和 feDisplacementMap svg滤镜
  • filter 属性
  • Canvas 绘制方法
  • TimelineMax 动画
  • input[type=file] 本地图片资源加载

想了解其他前端知识或其他未在本文中详细描述的 Web 3D 开发技术相关知识,可阅读我往期的文章。转载请注明原文地址和作者。如果觉得文章对你有帮助,不要忘了一键三连哦 👍。

附录

  • 我的3D专栏可以点击此链接访问 👈
  • [1]. 🌐 使用Three.js实现炫酷的赛博朋克风格3D数字地球大屏
  • [2]. 🦊 Three.js 实现3D开放世界小游戏:阿狸的多元宇宙
  • [3]. 🔥 Three.js 火焰效果实现艾尔登法环动态logo
  • [4]. 🐼 Three.js 实现2022冬奥主题3D趣味页面,含冰墩墩
  • ...
  • [1]. 📷 前端实现很哇塞的浏览器端扫码功能
  • [2]. 🌏 前端瓦片地图加载之塞尔达传说旷野之息
  • [3]. 😱 仅用CSS几步实现赛博朋克2077风格视觉效果
  • ...

参考

  • [1]. developer.mozilla.org/zh-CN/docs/…
  • [2]. developer.mozilla.org/zh-CN/docs/…
  • [3]. developer.mozilla.org/zh-CN/docs/…
  • [4]. developer.mozilla.org/zh-CN/docs/…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

计算机系统

发表于 2022-07-31

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

计算机系统是程序员的知识体系中最基础的理论知识,你越早掌握这些知识,你就能越早享受知识带来的 “复利效应”。

本文是计算机系统系列的第 4 篇文章,完整文章目录请移步到文章末尾~

前言

在日常开发过程中,Unicode & UTF-8 并不是很受关注的知识,但在阅读源码或文章时,出现频率很高。如果你没有理解清楚 Unicode、UTF-8、UTF-16 和 UTF-32 之前的关系,会带来阅读障碍。在这篇文章里,我将带你理解 Unicode 字符集的原理,希望能帮上忙。


  1. 什么是字符编码

1.1 什么是字符?

字符(Character) 是对文字和符号的总称,例如汉字、拉丁字母、emoji 都是字符。在计算机中,一个字符由 2 部分组成:

  • 1、字符的编码: 字符在计算机上的存储格式。例如在 ASCII 字符集中,字符 A 的编码就是 65;
  • 2、用户看到的图画: 字符的编码经过软件渲染后,呈现给用户的图画。同一个编码,在不同的软件渲染后呈现的图画可以是不同的。披萨的 Unicode 编码是 U+1F355,而在不同的软件渲染的图片可能是不同的。

你经常会在很多词语上看到 “编码” 这个单词,对初学者来说很容易混淆。今天我列举出 “编码” 常见的 3 层解释,希望能帮助你以后在阅读文章时快速理解作者的意思。

  • 含义 1 - 作为动词: 表示把一个字符转换为一个二进制机器数的过程,这个机器数才是字符在计算机中真实存储/传输的格式。例如把 A 转换为 65(ASCII) 的动作,就是一个编码动作;
  • 含义 2 - 作为名词: 表示经过编码动作后得到的那个机器数,对于 A 来说,65(ASCII) 就是 A 的编码(值),有时会称为编号;
  • 含义 3 - 作为名词: 表示把字符转换为机器数的编码方案,例如 ASCII 编码、GBK 编码、UTF-8 编码。

1.2 什么是字符集

字符集(Character Set) 是多个字符与字符编码组成的系统,由于历史的原因,曾经发展出多种字符集,例如:

Untitled 1.png

字符集一多起来,就容易出现兼容问题: 即同一个字符在不同字符集上对应不同的字符编码。 例如,最早的 emoji 在日本的一些手机厂商创造并流行起来,使得 emoji 在不同厂商的设备间无法兼容。要想正确解析一个字符编码,就需要先知道它使用的字符编码集,否则用错误的字符集解读,就会出现乱码。想象一下,你发送的一个在女朋友的手机上看到的是另一个 emoji,是一件多么可怕的事情。


  1. 认识 Unicode 字符集

2.1 为什么要使用 Unicode 字符集?

为了解决字符集间互不兼容的问题,包罗万象的 Unicode 字符集出场了。Unicode(统一码)由非营利组织统一码联盟负责,整理了世界上大部分的字符系统,使得计算机可以用更简单统一的方式来呈现和处理文字。

Unicode 字符集与 ASCII 等字符集相比,在概念上相对复杂一些。我们需要从 2 个维度来理解 Unicode 字符集:编码标准 + 编码格式。

2.2 Unicode 编码标准

关键理解 2 个概念:码点 + 字符平面映射:

  • 码点(Code Point): 从 0 开始编号,每个字符都分配一个唯一的码点,完整的十六进制格式是 U+[XX]XXXX,具体可表示的范围为 U+0000 ~ U+10FFFF (所需要的空间最大为 3 个字节的空间),例如 U+0011 。这个范围可以容纳超过 100 万个字符,足够容纳目前全世界已创造的字符。

  • 字符平面(Plane): 这么多字符并不是一次性定义完成的,而是采用了分组的方式。每一个组称为一个**平面,**每个平面能够容纳 216=655362^{16} = 65536216=65536 个字符。Unicode 一共定义了 17 个平面:
    • 基本多文种平面(Basic Multilingual Plane, BMP): 第一个平面,包含最常用的通用字符。当然,基本平面并不是填满的,而是刻意空出一段区域,这个我们下文再说。
    • 辅助平面(Supplementary Plane): 剩下的 16 个平面,包含多种语言的字符。

完整的 unicode 码点列表可以参考:unicode.org

2.3 Unicode 编码格式

Unicode 本身只定义了字符与码点的映射关系,相当于定义了一套标准,而这套标准真正在计算机中落地时,则有多种编码格式。目前常见到的有 3 种编码格式:UTF-8、UTF-16 和 UTF-32。UTF ****是英文 Unicode Transformation Format 的缩写,意思是 Unicode 字符转换为某种格式。

别看编码格式五花八门,本质上只是出于空间和时间的权衡,对同一套字符标准使用不同的编码算法而已。 举个例子,字符 A 的 Unicode 码点和编码如下(先不考虑字节序):

  • 1、图像:A
  • 2、码点:U+0041
  • 3、UTF-8 编码:0X41
  • 4、UTF-16 编码:0X0041
  • 5、UTF-32 编码:0X00000041

当你根据 UTF-8、UTF-16 和 UTF-32 的编码规则进行解码后,你将得到什么结果呢?是的,它们的结果都是一样的 —— 0x41。懂了吗?


  1. Unicode 的三实现方式

这一节,我们来讨论 Unicode 最常见的三种编码格式。

3.1 UTF-32 编码

UTF-32 使用 4 个字节的定长编码, 前面说到 Unicode 码点最大需要 3 个字节的空间,这对于 4 个字节 UTF-32 编码来说就绰绰有余。

  • 缺点: 任何一个码点编码后都需要 4 个字节的空间,每个字符都会浪费 1~3 个字节的存储空间;
  • 优点: 编解码规则最简单,编解码效率最快。

UTF-32 编码举例

1
2
3
ini复制代码U+0000   => 0x00000000
U+6C38 => 0x00006C38
U+10FFFF => 0x0010FFFF

3.2 UTF-16 编码

UTF-16 是 2 个字节或 4 个字节的变长编码,结合了 UTF-8 和 UTF-32 两者的特点。 前面提到 Unicode 码点最大需要 3 个字节,那么当 UTF-16 使用 2 个字节空间时,岂不是不够用了?

先说 UTF-16 的编码规则:

  • 规则 1: 基本平面的码点(编号范围在 U+0000 ~ U+FFFF)使用 2 个字节表示。辅助平面的码点(编号范围在 U+10000 ~ U+10FFFF 的码点)使用 4 个字节表示;
  • 规则 2: 16 个辅助平面总共有 2202^{20}220 个字符,至少需要 20 位的空间才能区分。UTF-16 将这 20 位拆成 2 半:
    • 高 10 位映射在 U+D800 ~ U+DBFF,称为高位代理(high surrogate);
    • 低 10 位映射在 U+DC00 ~ U+DFFF,称为低位代理(low surrogate)。

好复杂,为什么要这么设计?第一条规则比较好理解,1 个平面有最大的编码是 U+FFFF,需要用 16 位表示,用 2 个字节表示正好。第二条规则就不好理解了,我们重点说一下。

辅助平面最大的字符是 U+10FFFF,需要使用 21 位表示,用 4 个字节表示就绰绰有余了,例如说低 16 位 放在低 16 位,高 5 位放在高 16 位(不足位补零)。这样不是很简单也很好理解?

不行,因为前缀有歧义。 这种方式会导致辅助平面编码的每 2 个字节的取值范围都与基本平面的取值范围重复,因此,解码程序在解析一段 UTF-16 编码的字符流时,就无法区分这 2 个字节是属于基本平面字符,还是属于辅助平面字符。

为了解决这个问题,必须实现前缀无歧义编码(PFC 编码,类似的还有哈弗曼编码)。UTF-16 的方案是将用于基本平面字符编码的取值范围与辅助平面字符编码的取值范围错开,使得两者不会出现歧义(冲突)。这么做的前提,就需要在基本平面中提前空出一段区域,这就是上文提到基本平面故意空出一段区域的原因。

如下图所示,在基础平面中,浅灰色的 D8 ~ DF 为 UTF-16 代理区:

—— 图片引用自维基百科

UTF-16 编码举例

到这里,UTF-16 的设计思路就说完了,下面就会解释具体的计算规则,不感兴趣可以跳过。


  • 1、辅助平面字符的范围是 U+10000 ~ U+10FFFF,换句话说,第一个辅助平面字符是 U+10000。那么就可先把每个码点减去 0x10000,映射到 U+0000 ~ U+0AFFFF,这样的好处是只需要 20 位就能表示所有辅助平面字符(否则需要 21 位);
  • 2、20 位正好可以拆分为 2 组:高 10 位作为一组,低 10 位作为一组,则有 codepoint=high<<10+low+0x10000code point = high << 10 + low + 0x10000codepoint=high<<10+low+0x10000
  • 3、highhighhigh 和 lowlowlow 会与基本平面冲突,那么就给它们分别加上一个偏移量,使它们落到基本平面中空出来的代理区(highhighhigh 偏移 0xD800,low 偏移 0xDC00)。

至此,UTF-16 字符编码完成。计算公式总结:

codepoint=((high−0xD800)<<10)+low−0xDC00+0x10000code point = ((high - 0xD800)<< 10 ) + low - 0xDC00 + 0x10000codepoint=((high−0xD800)<<10)+low−0xDC00+0x10000

high=(codepoint−0x10000)>>>10+0xD800high = (codepoint - 0x10000) >>>10 + 0xD800high=(codepoint−0x10000)>>>10+0xD800

low=(codepoint & 0x3FFF)+0xDC00low = (codepoint\ & \ 0x3FFF) + 0xDC00low=(codepoint & 0x3FFF)+0xDC00w


我们在 Java 源码中寻找一下这套计算规则,具体在 String 和 Character 中:

String.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
java复制代码public String(int[] codePoints, int offset, int count) {
// 0. 前处理:参数不合法的情况
final int end = offset + count;

// 1. 计算总共需要的char数组容量
int n = count;
for (int i = offset; i < end; i++) {
int c = codePoints[i];
// 分析点 1.1
if (Character.isBmpCodePoint(c))
continue;
// 分析点 1.2
else if (Character.isValidCodePoint(c))
n++; // 每个辅助平面字符需要多一个char
else throw new IllegalArgumentException(Integer.toString(c));
}

// 2. 分配数组并填充数据
final char[] v = new char[n];
for (int i = offset, j = 0; i < end; i++, j++) {
int c = codePoints[i];
// 分析点 2.1
if (Character.isBmpCodePoint(c))
v[j] = (char)c;
else
// 分析点 2.2
Character.toSurrogates(c, v, j++);
}
// 结束
this.value = v;
}

编码计算:

Character.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码// 分析点 1.1:判断码点是否处于基本平面
public static boolean isBmpCodePoint(int codePoint) {
return codePoint >>> 16 == 0;
}
// 分析点 1.2:判断码点是否处于辅助平面
public static boolean isValidCodePoint(int codePoint) {
int plane = codePoint >>> 16;
return plane < ((0x10FFFF + 1) >>> 16);
}
// 分析点 2.2:辅助平面字符 - 规则2
static void toSurrogates(int codePoint, char[] dst, int index) {
// high在高位,low在低位,是大端序
dst[index+1] = lowSurrogate(codePoint);
dst[index] = highSurrogate(codePoint);
}
// 计算高位代理
public static char highSurrogate(int codePoint) {
return (char) ((codePoint >>> 10) + (0xDBFF - (0x010000 >>> 10)));
}
// 计算低位代理
public static char lowSurrogate(int codePoint) {
return (char) ((codePoint & 0x3ff) + 0xDC00);
}

解码计算:

Character.java

1
2
3
4
java复制代码public static int toCodePoint(char high, char low) {
// 源码有算术表达式优化,此处为等价逻辑
return ((high - 0xD800) << 10) + (low - 0xDC00) + 0x010000;
}

3.3 UTF-8 编码

UTF-8 是 1~4 个字节的变长编码,相对来说最节省空间。 下述规则表述与你在任何文章 / 百科里看到的规则表述不一样,但是逻辑上是一样的。因为我认为按照 “前缀无歧义” 的概念来理解最易懂。

  • 规则 1: 不同范围的码点值使用不同长度的编码;
  • 规则 2: 字节编码总长度为 1 时前缀为 0、总长度为 2 时前缀为 110、总长度为 3 时前缀为 1110、总长度为 4 时前缀为 11110 ;
  • 规则 3: 除了首个字节,字符编码中其余字节的前缀为 10。

可以看到,这种编码方式是不会存在前缀歧义的,也比较好理解。

UTF-8 编码举例

因为 UTF-8 编码相对来说是最节省空间的,因此在很多存储和传输的场景中,都会选择使用 UTF-8 编码。例如:

  • 1、XML文件的编码: 在文件头定义了编码格式。
1
xml复制代码<?xml version="1.0" encoding="utf-8"?>
  • 2、Java 字节码中字符串常量的编码: 可以看到,Class 文件中的字符串常量是 UTF-8 编码的,并且长度最大只支持 u2(65535 个字符),这就是在 Java 中定义的变量名标识符或方法名标识符过长(超过 64 KB)将无法通过编译的根本原因。
类型 标识 描述
CONSTANT_Utf8_info 1 UTF-8 编码的字符串
CONSTANT_String_info 8 字符串类型字面量

其中CONSTANT_Utf8_info常量的结构:

名称 类型 数量
tag u1 1
length u2 1
bytes u1 length
  • 3、HTTP报文主体的编码: ****HTTP 报文首部字段 Content-Type 可以指定字符编码方式。在 OkHttp 源码中,当响应报文首部字段 Content-Type 缺省时,默认按 UTF-8 解码,看源码:

Http 报文示例

1
2
3
4
5
css复制代码HTTP/1.1 200 OK
... 省略
Content-Type:text/html; charset=UTF-8

[报文主体]

OkHttp 源码摘要:

ResponseBody.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cpp复制代码public final String string() throws IOException {
BufferedSource source = source();
try {
// 分析点 1
Charset charset = Util.bomAwareCharset(source, charset());
return source.readString(charset);
} finally {
Util.closeQuietly(source);
}
}
// 分析点1:获得解码需要的charset
private Charset charset() {
// contentType为null时,使用 UTF_8
MediaType contentType = contentType();
return contentType != null ? contentType.charset(UTF_8) : UTF_8;
}

  1. UTF 的字节序问题

上一节,我们讨论了 Unicode 三种编码格式的编码规则,在实际中还需要考虑多字节数据的排列顺序的因素, 即字节序(Byte Order):字节序考虑的是多字节编码单元内的字节排列顺序问题,存在大端序和小端序两种,大端序更适合人类解读,而小端序更适合计算机解读。对于以单个字节作为编码单元的数据,不存在字节序问题,例如 ASCII 编码就没有字节序问题。

  • 大端字节序(Big Endian): 高位字节在前,低位字节在后;
  • 小端字节序(Little Endian): 高位字节在后,低位字节在前。

我一开始以为字节序是描述整块数据的排列顺序,比如大端序就是整个文件从头到尾正向排序,而小端序就是整个文件从头到尾逆向排序,这显然是错误的。 字节序描述的是一个编码单元内部的字节顺序,而编码单元永远是正向排序(即大端序)。

举个例子,有数据 0x1122 3344 以每两个字节为一个编码单元,则有:

  • 大端序:0x1122 3344
  • 小端序:0x2211 4433(不管怎么排列,3344 单元永远在 1122 单元后)

可以看到,大端序这种从高到低(或者从左到右)的排列方式,是更加符合人类的阅读习惯的。相较之下小端序简直反人类,我们要把编码单位内部的数据逆序才能读出正确的数值。那么为什么要设计小端序呢,统一使用大端序不行吗?可以,但是有点反机器:) 因为计算机内部的奇偶校验、加减乘除、比较大小等操作,都是小端序效率更高。 具体我们就展开了,你可以看这篇文章:理解字节序

理解了字节序后,回过头再来看 UniCode 编码加上字节序的要素后,则会存在以下几种分类:

编码格式 编码单元长度 BOM 字节序
UTF-8-无BOM 1 ~ 4 字节 无 大端序
UTF-8 1 ~ 4 字节 EF BB BF 大端序
UTF-16-无BOM 2 / 4 字节 无 大端序
UTF-16BE(默认) 2 / 4 字节 FE FF 大端序
UTF-16LE 2 / 4 字节 FF FE 小端序
UTF-32-无BOM 4 字节 无 大端序
UTF-32BE(默认) 4 字节 00 00 FE FF 大端序
UTF-32LE 4 字节 FF EE 00 00 小端序

UTF-16 和 UTF-32 不难理解,它们在文件开头使用一个特殊的字符 U+FEFF 作为 BOM(Byte Order Mark)标示文件使用字节序:

  • 文件头部的 BOM 是 FEFF :大端序;
  • 文件头部的 BOM 是 FFFE: 小端序;
  • 文件头部无 BOM: 默认视为大端序。

U+FEFF 是一个特殊的字符,叫作 Zero With No-Bread Space 零宽度无间断字符 ,它在显示时是看不到的空白字符,但会确保词语在换行时不会中断。例如在 +1 中间插入 U+FEFF,则渲染时可以保证 + 和 1 不会换行,但中间也不会有空格出现,所以叫零宽度无间断字符。

不过, U+FEFF 也不是一直作为零宽度无间断字符使用,如果它处于文件头部,则它会作为 BOM 来标示文本使用的字节序,而不会被当作文本的一部分。只有 U+FEFF 出现在文本中间时,才会被当作零宽度无间断字符使用。为了确保在文件的头部也能够使用零宽度不间断空间,Unicode 3.2 添加了一个新的 U+2060 字符 “WORD JOINER”,具有完全相同的语义,但不会被当作 BOM。

最后一个问题,为什么 UTF-8 不需要区分字节序,而 UTF-16 和 UTF-32 需要区分字节序呢?网上能看到的观点都是说 UTF-16 和 UTF-32 以多字节为编码单元所以需要区分字节序,而 UTF-8 是以单字节作为编码单元,所以不需要区分字节序。 这显然是睁眼说瞎话:),UTF-8 怎么会是单字节编码呢?

我的观点是: 无论使用大端序还是小端序,UTF-16 和 UTF-32 最多只需要解读首个字节就可获得当前字符的编码长度,而 UTF-8 在使用小端序时无法通过首个字节获得当前字符的编码长度。 具体来说,UTF-32 是定长编码,编码长度是固定的;UTF-16 表示 2 字节编码和 4 字节编码使用字节取值范围是错开的,因此只要看一个字节就知道编码长度;而 UTF-8 需要通过首个字符的前缀才能知道编码长度,如果使用小端序的话,计算机需要继续往后读取最多 3 个字节才能知道编码长度,所以小端序就没有意义了,因此 UTF-8 不会存在小端序。

UTF-8 编码使用 BOM EF BB BF 只是标记该文本是 UTF-8 编码,并不是标记字节序。


  1. 总结

用一张表总结一下 3 种编码格式:

ASCII UTF-8 UTF-16 UTF-32
编码空间 0~7F 0~10FFF 0~10FFF 0~10FFF
最小存储占用 1 1 2 4
最大存储占用 1 4 4 4

参考资料

  • Unicode —— 维基百科
  • UTF-8, a transformation format of ISO 10646 —— 互联网工程任务组(IETF)
  • UTF-16, a transformation format of ISO 10646 —— 互联网工程任务组(IETF)
  • Unicode Format for Network Interchange —— 互联网工程任务组(IETF)
  • 《编码·隐匿在计算机软硬件背后的语言》(第23章) —— [美] Charles Petzold 著
  • 隔空传情: emoji 简史 —— Google Play
  • 字符编码笔记:ASCII,Unicode 和 UTF-8 —— 阮一峰 著
  • Unicode 与 JavaScript 详解 —— 阮一峰 著
  • 阮一峰老师文章的常识性错误之 Unicode 与 UTF-8 —— 刘志军 著

推荐阅读

计算机系统系列完整目录如下(2023/07/11 更新):

  • #1 从图灵机到量子计算机,计算机可以解决所有问题吗?
  • #2 一套用了 70 多年的计算机架构 —— 冯·诺依曼架构
  • #3 为什么计算机中的负数要用补码表示?
  • #4 今天一次把 Unicode 和 UTF-8 说清楚
  • #5 为什么浮点数运算不精确?(阿里笔试)
  • #6 计算机的存储器金字塔长什么样?
  • #7 程序员学习 CPU 有什么用?
  • #8 我把 CPU 三级缓存的秘密,藏在这 8 张图里
  • #9 图解计算机内部的高速公路 —— 总线系统
  • #10 12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?
  • #11 已经有 MESI 协议,为什么还需要 volatile 关键字?
  • #12 什么是伪共享,如何避免?

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

使用 ahooks - useRequest 轻松实现乐观更

发表于 2022-07-30

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情

这是我关于 ahooks - useRequest 系列文章的第二篇,其他两篇请查看:

  • 使用 ahooks 中的 useRequest 轻松管理React中的网络请求
  • 使用 ahooks - useRequest 轻松实现乐观更新
  • React 优化:在 ahooks - useRequest 中利用 swr 优化网络请求

在上一篇文章:使用 ahooks 中的 useRequest 轻松管理React中的网络请求 ,我们介绍了 useRequest 这个 Hook 的使用以及配置。

本文我们主要介绍如何通过 useRequest 实现乐观更新。

什么是乐观更新

乐观更新 Optimistic Updates,即不等待接口返回数据,在发起请求时就对数据进行修改,提前将交互结果显示在用户界面上。

一般的应用乐观更新的场景,是对数据源的可预测修改,即请求发出后我们可以知道成功 或者 失败 会对源数据产生什么影响。

举个栗子,假如我们要对用户信息进行修改一般涉及到如下两个接口:

  1. [get] api/user/id 获取到用户信息
  2. [put] api/user/id 修改用户信息

一般来说,接口2 在提交成功后只会返回成功或者失败的信息,那么我们通常是在接口2调用成功后,再次请求接口1更新页面。

就像我前面说的这个概念这是一个非常典型的可预测修改,我们其实完全没有必要再次请求接口1,完全可以使用我们自己已有的数据来修改原来的数据源,从而减少网络请求次数、提高用户界面响应速度。

普通:接口1 -> 接口2 -> 接口1,一发送3个请求需要等待时间较长,用户界面响应取决于网络响应

乐观更新:接口1 -> 接口2 (本地修改数据源),只发送两次请求,只等待一次接口1的响应

简单来说乐观更新的概念,就是我们乐观的认为这个修改请求会成功,并且我们可以预测成功或失败后数据源如何变化,在真实数据(服务端状态)变化之前,修改用户本地数据刷新用户界面。

如何实现

这里就要请出我们在上一篇文章介绍的 mutate 函数与生命周期回调,用我们上面修改用户信息的例子写一个简单的demo,我会分步骤来介绍每一个步骤的意义。

  1. 接口模拟
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
javascript复制代码// 模拟请求用户信息
const fetchUserInfo = (id) => {
 return new Promise((resolve, reject) => {
   setTimeout(() => {
     resolve({
       name: `John`,
       id,
       time: new Date().toLocaleString()
    });
  }, 500);
})
}

// 模拟修改用户信息接口请求成功 or 失败
const changeUserName = (name) => {
 return new Promise((resolve, reject) => {
   setTimeout(() => {
     if (Math.random() > 0.5) {
       resolve();
    } else {
       reject(new Error('Failed to modify username'));
    }
  }, 1000);
});
}
  1. 在组件中使用 useRequest
1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码// 接口1 获取用户信息接口
const { data, loading, error, run, refresh, mutate } = useRequest(() => fetchUserInfo(id));
// 使用 usePrevious 保存上一次的数据
const originData = usePrevious(data);
// 接口2 修改用户接口
const { run: updateName } = useRequest(changeUserName, {
   manual: true,
   onError: () => {
     // 乐观更新失败使用原始数据恢复
     mutate(originData);
     alert('操作失败!');
  }
})

这里我们额外引入了 ahooks 中的一个 钩子函数:usePrevious,它可以帮我们保存状态变更前的值,这样我们可以方便的通过它来回溯数据,这个钩子函数我们以后有机会会专门介绍一下。

这里要注意,mutate 函数是来自于数据源接口,用来操作数据源状态。用于引起突变的实际是接口2,我们将它设置为手动触发,并且配置生命周期函数。当请求成功时说明对服务端状态修改成功,一般来说不需要额外操作。失败时,说明乐观更新失败,我们需要用原始数据来恢复用户界面并对用户进行提示。
3. 处理突变

1
2
3
4
5
6
7
8
9
10
11
scss复制代码const handleChange = () => {
   if (name) {
     // 发起修改请求
     updateName(name);
     // 发起请求时通过mutate函数乐观更新原始数据
     mutate(state => ({
       ...state,
       name
    }))
  }
}

处理突变这里很好理解,就是引起突变的请求发出后,我们就应该立即使用 mutate 函数,改变原始数据,进行乐观更新。

题外话

在 ahooks 中实现乐观更新的操作步骤上虽然有一点繁琐,但是胜在逻辑很容易理解。比较起 react-query 来说,useRequest 需要多些一些代码,例如需要自己保存修改之前的原始数据(用于突变失败后的数据恢复)。

一般来说我们在大多数情况都不是必须使用乐观更新,他不是一个必选项,只有在频繁涉及到这种可预测修改时,可以使用乐观更新来优化用户体验。

乐观更新的限定一定是可预测修改,如果一个操作之后对用户界面的影响是未知的,那么我们只能按照原来的方式处理。

个人感官上,useRequest 可以作为接触 服务端状态管理 的入门,如果我们要深入的学习理解 服务端状态管理 ,还是需要去学习 诸如 react-query 或者 rtk-query 的。

上文演示的乐观更新其实并不适合应用于生产,这更多是一个入门的demo,我会在下一篇文章介绍 swr、数据缓存、数据共享时,改造这段代码,让他更符合真实应用时的使用方法,敬请期待!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

图文结合简单易学的npm 包的发布流程 1代码准备 2账

发表于 2022-07-29

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情 >>

聪明的你做了几个项目之后,有没有发现发现某些工具方法或者组件的使用频率很高,好多项目都在用。如何做到这些工具方法或者组件的更优雅地复用而不是用到了就复制粘贴呢?封装为一个npm包是一个不错的选择。本文以图文结合的方式介绍了如何从0到1发布一个npm包,文中的一些关键点的说明将帮你避坑提效。欢迎阅读学习~

1.代码准备

每个人要发布的npm包类型都不尽相同,有UI组件库,有工具函数库,还有使用的插件等。笔者要发布的npm包是在项目中常用的工具函数组成的工具函数库,构建工具使用的是rollup,代码托管在github上。下面简述一下一些关键点:

  • 首先在github上新建仓库,新建仓库时License 选择MIT, 此步骤不选择也无妨,后续添加license也可以。但是一定要有License才能发布npm包。

  • 拉取代码到本地
  • 初始化项目,安装依赖等

  • 完善功能
  • 打包,并在package.json中指明入口

)

另外如果发布公有包需要在package.json中增加publishConfig的配置

1
2
3
json复制代码"publishConfig": {
"access": "public"
},

更多关于项目的搭建以及一些配置方面的内容建议阅读文末的参考资料。

2.账号注册

先看下图了解注册的流程:

网址:www.npmjs.com/signup

输入网址后会进行安全性检查,之后界面如下:

点击”我是人类” 会进行图片验证,如下图:

图片验证完就是输入用户名、密码、邮箱过程,

最后让输入one-time-password,这个一次性密码(相当于验证码)会发到你预留的邮箱里面。填写之后应该会注册成功的。

3.npm包发布

3.1 登录npm账号

3.1.1 登录失败

执行npm login 命令登录npm :

如上图所示,登录失败了。解决办法: 使用nrm切换镜像,将镜像改为npm。下面简要介绍一下nrm。

3.1.2 nrm 介绍

nrm 用于管理镜像,是一个可以切换npm镜像的管理工具。如下是安装和查看是否安装成功的命令:

1
2
css复制代码npm i -g nrm
nrm -V

常用nrm命令如下:

想了解更多关于nrm的内容请查看文档和资料。

下图是使用nrm ls命令查看镜像:

下图是将镜像切换为npm

3.1.3 成功登录

切换镜像之后再登录:

如上图登录时需要输入OTP,要查看邮箱。输入OTP回车之后就可以成功登录了

3.2 如何发布npm包

3.2.1 首次发布成功

登录成功之后即可执行发布命令:npm publish,如下图:

此时npm包发布成功了。

3.2.2 名字相似发布失败

但是感觉名字’mxdevutil’可读性不咋好啊,所以改了一下名字,新名字为’mx-dev-util’重新发布,但却报错,如下图所示:

上图报错信息告诉我们:新包的名字和已有的包名字很相似,所以没有发布成功。解决的方法之一是可以起区分度较大的名字,但删掉重新发布更好。

3.3 如何删除npm 包

3.3.1 废弃npm 包

查资料所如下命令可以删掉发布错误的npm包:

但实际上此命令是表示废弃已发布的npm包,并不是删除。

在npm网站上仍然能够查到已废弃的npm包,如下图所示:

废弃之后是否可以发布成功呢?重新执行npm publish

执行结果如下图:

还是报错,所以单单废弃原有包还是不能发布新包的。

3.3.2 删除npm包

正确的解决办法是:npm unpublish <报名> -force ,命令执行效果:

再在npm网站上查找则查不到了:

删掉已发布的包之后,终于可以重新发布了:

发布成功!在npm网站上也能够看到新包了:

3.4 如何使用npm包

首先安装我们发布的npm包,执行命令 npm i mx-dev-util, 如下图:

可以查看package.json文件,返现已经将mx-dev-util加添为dependiences:

接着就是在项目中使用npm包提供的方法了:

3.5 更新npm包版本

更新npm包两步走:

  • 第一步:执行npm version <版本号类型>
  • 第二步:执行 npm publish

3.5.1 npm version介绍

npm version命令使用方式如下:

1
复制代码npm version  major | minor | patch | premajor | preminor | prepatch | prerelease

这里简单介绍一下major | minor | patch 的区别:在package.json中有一个version字段,结构为 “x.y.z” ,也就是三位的版本号。分别对应 version里面的major、minor,patch。

如果当前版本为 0.0.1 则发布major、minor,patch版本之后会变为 1.0.0 ,0.1.0, 0.0.2。导图总结如下:

了解更多可查看npm version文档和相关资料。

了解了npm version命令之后,执行npm version major :

执行命令失败,提示需要先提交代码,下图为提交代码过程:

提交代码后,再执行版本更新命令:

3.5.2 改版后发布

提示版本已经更新为2.0.0版本,然后执行 npm publish 命令:

可以看到版本更新成功。

下面总结一下用到发布npm包用到的npm命令

4.总结

(1)本文介绍发布一个npm包的三个关键环节:

  • 发布内容。也就是代码,这是npm包的基础
  • 注册npm账号。这是能够成功执行npm 发布命令的前提
  • npm包发布。掌握npm 包发布的这些命令是关键

(2)本文介绍了如何使用nrm 切换npm的镜像

希望看完本文对您有帮助,表达不清楚或者写错的地方欢迎不吝指正~

参考资料:

[1] npm包发布详细教程

[2] 如何发布自己的npm包(超详细步骤,博主都在用)

[3] 如何发布一个npm包

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

使用 ahooks 中的 useRequest 轻松管理Re

发表于 2022-07-29

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

这是我关于 ahooks - useRequest 系列文章的第一篇,其他两篇请查看:

  • 使用 ahooks 中的 useRequest 轻松管理React中的网络请求
  • 使用 ahooks - useRequest 轻松实现乐观更新
  • React 优化:在 ahooks - useRequest 中利用 swr 优化网络请求

关于 ahooks

2022年的今天,在 React 中使用 Hook 已经是常规的不能再常规的操作了,我们会大量的通过组合 React 提供的 Hook,创建属于自己业务的专属自定义 Hook,亦或是各种工具 Hook。

阿里前端团队出品的 ahooks 正是这样一套 Hook 工具集,里面提供数十个常用的 Hook,可以极大的方便我们的日常开发。

今天我们要着重介绍的。就是 ahooks 项目中最为重量级的一个 hook ,useRequest。

为什么用 useRequest

为什么要用 useRequest,其实要从状态说起。在过去项目开发中我们经常将 用户 UI 交互状态 与 服务端状态 混为一谈,经常是 Redux 一把嗦,通过 fetch 获取到数据用,使用 useState 将数据作为状态,最终渲染到页面。

这样做看起来并没有什么问题,很多项目也都是这样写的。但是 用户 UI 交互状态 是实时性较强的,是我们作为前端可控的状态,而 服务端状态 则涉及到 http 请求,他是异步的、是不完全可控的。

例如一个网络请求其实不光只有最终的数据这一个状态,还有 loading、error等状态,可能还涉及到请求失败后的 retry,一些服务端状态变更不频繁、但是前端需要频繁调用的,还涉及到数据的状态缓存与共享。

仅仅是上面那我们说的这些就已经涉及了大量的状态管理,这样的状态管理显然是有别于 用户 UI 交互状态 的,因此 SWR、react-query、rtk-query这些专注于服务端状态管理的库应运而生.

如果你在之前接触过 SWR 或者是 react-query,那么你可能会比较好的理解 useRequest,你可以将它看作是一个轻量级的 react-query。

快速上手

我们写一个模拟请求用户信息的组件,我们用普通的方式写的话,大致是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
javascript复制代码// 模拟异步请求
const fetchUserInfo = () => {
 return new Promise((resolve, reject) => {
   setTimeout(() => {
     const random = Math.random();
     if (random > 0.5) {
       resolve({
         name: 'John',
         time: new Date().toLocaleString()
      });
    } else {
       reject('这是一个随机的 Error');
    }
  }, 1000);
})
}

function App() {
 const [data, setData] = useState();
 const request = async () => {
   const resp = await fetchUserInfo();
   setData(JSON.stringify(resp));
}
 return (
   <div className="App">
     <button onClick={request}>Requst</button>
     <div>{data}</div>
   </div>
);
}

就像我们之前说的,这种普通的请求方式缺少 loading、error等状态,我们只需要简单的修改代码就能通过 useRequest 实现。

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码function App() {
 const { data, loading, error, run } = useRequest(fetchUserInfo);
 return (
   <div className="App">
     <button onClick={run}>Requst</button>
    {loading && <div>Loading...</div>}
    {error && <div>Error: {error}</div>}
    {data && <div>{JSON.stringify(data)}</div>}
   </div>
);
}

如你所见,他真的非常简单,我们只需要传入一个 Promise ,剩下的全部交给 useRequest 就可以了!

useRequest 的第一个参数 service 是一个异步函数,在组件初次加载时,会自动触发该函数执行。同时自动管理该异步函数的 loading , data , error 等状态。

手动请求

useRequest 是自定触发请求的,也就说当组件挂载到 DOM 树上后,请求会立即发出。如果我们希望手动触发请求,可以为 useRequest 函数配置第二个参数。

第二参数是一个 options 配置,如果设置了 options.manual = true,则 useRequest 不会默认执行,需要通过 run 来触发执行。

1
2
3
arduino复制代码const { data, loading, error, run } = useRequest(fetchUserInfo, {
   manual: true
});

刷新

不同于 run 函数可以接收参数,refresh 不接受参数,它会自动的使用上一次调用传入的参数来发起请求。

1
2
3
arduino复制代码const { data, loading, error, run, refresh} = useRequest(fetchUserInfo, {
   manual: true
});

取消请求 cancel

一般来说,我们不需要处理取消请求,useRequest 会自动在组件卸载时取消请求。

1
go复制代码const { data, loading, error, cancel} = useRequest(fetchUserInfo);

突变 mutate

突变这个概念在 SWR、react-query 中也是存在的,在 ahooks 中使用它非常简单,我们可以把它看成是一个 setData 函数,它可以修改由 useRequest 返回的 data 状态。

1
kotlin复制代码const { data,  mutate} = useRequest(fetchUserInfo);

它往往被用于乐观更新这样的场景!我会在后续文章中介绍如何使用 ahooks 的 useRequest 实现乐观更新。

options 可配置项简介

除了上面我们提到的 options.manual = true ,用于配置手动执行请求之外,options还有很多可选的配置,接下来我们简单介绍几个常用的配置。

1. 传递参数给 service

上面我们介绍了,参数1 service 一般是一个 结果值为 Promise 的函数,如果我们需要传参给这个函数应该怎么做?

答案还是 options,我们可以配置options.defaultParams 字段,如果只有一个参数,直接赋值即可,多参数则赋值一个元组即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
javascript复制代码function App() {
 const { data, loading, error, run } = useRequest(fetchUserInfo, {
   defaultParams: [
     1, 'unknown'
  ]
});
 return (
   <div className="App">
     <button onClick={() => run(123, 'man')}>Requst</button>
    {loading && <div>Loading...</div>}
    {error && <div>Error: {error}</div>}
    {data && <div>{JSON.stringify(data)}</div>}
   </div>
);
}

2. loading状态延时 loadingDelay

一般我们会通过{loading && <div>Loading...</div>} 这样的代码来展示一段加载中动画,但是有时候在网络良好的状况下这个 loading 状态的持续时间非常短暂,如果这个请求是比较频繁调用的,那么就会不断的闪烁 加载动画,这样的体验是很差的,我们可以通过 loadingDelay 配置当 loading时间超过这个值时才展示loading动画

1
2
3
4
php复制代码const { data, loading, error, run } = useRequest(fetchUserInfo, {
 defaultParams: 222,
 loadingDelay: 300
});

3. 生命周期

useRequest 提供了多个生命周期回调函数:

  1. onBefore?: (params: TParams) => void; //在请求之前
  2. onSuccess?: (data: TData, params: TParams) => void; //请求成功
  3. onError?: (e: Error, params: TParams) => void; //请求失败
  4. onFinally?: (params: TParams, data?: TData, e?: Error) => void; //请求结束
1
2
3
4
5
6
7
javascript复制代码  const { data, loading, error, run, refresh } = useRequest(fetchUserInfo, {
   defaultParams: 222,
   loadingDelay: 300,
   onSuccess: (data) => { console.log('请求成功了'); },
   onError: (err) => { console.log('请求失败了'); },
   onFinally: () => { console.log('请求结束了'); }
});

4. 轮询

我们只需要简单配置一个属性,就可以将请求设置为轮询模式。

1
2
3
go复制代码const { data, loading, error} = useRequest(fetchUserInfo, {
 pollingInterval: 3000,
});

我们还可以通过配置 pollingErrorRetryCount 字段,设置轮询错误重试次数。如果设置为 -1,则无限次,当轮询过程出错到达计数时,停止轮询。

配置 pollingWhenHidden 可以设置在页面隐藏时,是否继续轮询。如果设置为 false,在页面隐藏时会暂时停止轮询,页面重新显示时继续上次轮询。

如果设置 options.manual = true,则初始化不会启动轮询,需要通过 run/runAsync 触发开始。

5. ready

ready 的效果其实有点等同于 run 函数,每当 ready 变成 true 时,就会发起一次请求,调用参数1传入的 Promise 异步函数。需要注意的是有参数的情况,ready是用默认参数发起请求,即使后续多次 改变 ready 的状态,他也是使用我们设置的 options.defaultParams 来传递给 service 的。这一定一定要注意,他的调用效果相当于是拿着默认参数的 refresh!

它相当于一个提供了一个总开关,只有当该条件满足时才允许发送请求,无论时自动请求还是手动请求,都受到该字段限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
javascript复制代码function App() {
 const [ready, { toggle }] = useBoolean(false);
 const { data, loading, error, run, refresh } = useRequest(fetchUserInfo, {
   defaultParams: 222,
   ready,
   onSuccess: (data) => { console.log('请求成功了'); },
   onError: (err) => { console.log('请求失败了'); },
   onFinally: () => { console.log('请求结束了'); }
});

 return (
   <div className="App">
     <button onClick={() => run(Math.random(), 'man')}>Requst</button>
     <button onClick={refresh}>refresh</button>
     <button onClick={toggle}>toggle</button>
    {loading && <div>Loading...</div>}
    {error && <div>Error: {error}</div>}
    {data && <div>{JSON.stringify(data)}</div>}
   </div>
);
}

6. 依赖刷新

useRequest 提供了一个 options.refreshDeps 参数,当它的值变化后,会重新触发请求。它的效果基本等同于手写 useEffect。

例如:

1
2
3
4
scss复制代码  const [id, setId] = useState(555);
 const { data } = useRequest(() => fetchUserInfo(id), {
   refreshDeps: [id],
});

总结

行文至此,想必你应经对如何使用 useRequest 有了一定的了解,在后续文章中,我会继续介绍 useRequest 中的其他高级用法,例如 swr 缓存、请求的防抖、节流,还有上面我们说的 乐观更新 等等。

PS:再本文中,大写的 SW R特指的由 Next.js 团队推出的 SWR库,小写的 swr 则指的是 stale-while-revalidate 这一概念,请注意区分。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

不堆概念、换个角度聊JAVA多线程并发编程的防护策略

发表于 2022-07-28

大家好,又见面了。

在上一篇文档《JAVA基于CompletableFuture的流水线并行处理深度实践,满满干货》中,我们一起探讨了JAVA中并行编码的相关内容,在文中也一起比较了并行与并发的区别。作为姊妹篇,这里我们就再展开聊一聊关于并发相关的内容。

image.png

俗话说,双拳难敌四手。

俗话还说,人多力量大。

在现实生活中,我们通过团队化的方式来获得比单兵作战更高的单位时间内整体产出速度。同样,在编码世界中,为了提升处理效率,并发一直以来都是软件开发设计场景中无法绕过的话题。不管是微观层面的单个进程内多线程处理模式,还是宏观层面整个系统集群化多节点部署策略,为了提升系统的整体并发吞吐量,程序员们可谓是煞费苦心。

image.png

当然,俗话也说,人多眼杂、林子大了什么鸟都有。

在现实中,团队中多人一起配合工作的时候,一系列的问题又会显现:

  • 同一个事情,老王和小张都以为还没处理,结果都去处理了,最后造成了成员工作量的浪费、甚至因为重复处理了一遍导致数据错误
  • 两个有关联的事情分别给了老王和翠花,结果老王在等待翠花先给出结果再开始处理自己的事情,翠花也在等待老王先给出结果然后再处理自己的事情,结果两个人就这么一致等下去,事情一直没完成
  • 同一个文档,小张和翠花各自更新的时候,出现相互覆盖的情况
  • …

image.png

编码源于生活、代码世界其实也处处体现着生活中的朴素哲学思维。纵然并发场景存在一些可能的隐患问题,但我们也不必因噎废食,正所谓先了解它、再掌控它。

作为提升吞吐性能的不二良方,下面我们就一起来尝试按照问题解决型的思路一步步推进,换个角度探讨下多线程并发相关的内容,全面了解下多线程并发世界的各种关联,进而更从容优雅的让并发为我们所用,成为我们提升系统性能的神兵利器。

多线程——并发第一步

并发探险的第一关,就是如何支持并发。下面大概列举下常见的几种方式:

⭐️子线程⭐️

一些简单的场景中,我们为了提升主线程的处理性能,会将过程中一些耗时操作放到一个单独的子线程中进行同步处理。在代码中可以通过创建临时子线程的方式来执行即可:

1
2
3
4
5
6
7
java复制代码public void buyProduct() {
int price = getPrice();
// 子线程同步处理部分操作
new Thread(this::printTicket).start();
// 主线程继续处理其它逻辑
doOtherOperations(price);
}

⭐️线程池⭐️

频繁创建线程、销毁线程的操作属于一种消耗性能的操作,而且创建线程的数量不可控。所以对于一些固定需要在子线程中并行处理的任务场景,我们可以通过创建线程池的方式,固定维护着一批可用线程,循环利用,去处理任务,以实现提升效率与便于管控的诉求:

1
2
3
4
5
6
java复制代码private ExecutorService threadPool = Executors.newFixedThreadPool(3);

public void testReleaseThreadLocalSafely() {
// 任务直接放到线程池中进行处理
threadPool.submit(this::mockServiceOperations);
}

⭐️定时器⭐️

定时器是一种比较特殊的多线程并发场景,也是经常可能会被忽视掉的一种情况。定时器也是在子线程中执行的,多个定时器之间、定时器线程与主线程之间、定时器线程与业务子线程之间都会以多线程的形式并发处理。

1
2
3
4
java复制代码@Scheduled(cron = "0 0/10 * * * ?")
public void syncBusinessInfo() {
// do something here...
}

⭐️Tomcat等容器⭐️

常见的服务运行容器,比如Tomcat等,都是支持并发请求执行的。而常见的基于SpringBoot实现的服务,其service类都是由Spring进行托管的单例对象。这种场景是比较常见的多线程场景。

改为多线程并发执行,虽然效率是提升了,但是问题也来了——数据执行结果不准确。

结果不对,显然是我们无法接受的。所以摆在我们面前的下一难题,就是要保证执行结果数据的准确。

synchronized与lock

在JAVA中提到线程同步,使用最简单、应用频率最高的非synchronized关键字莫属了。它是 Java 内建的一种同步机制,代表了某种内在锁定的概念,当一个线程对某个共享资源加锁后,其他想要获取共享资源的线程必须进行等待,synchronized 也具有互斥和排他的语义。具体用法如下:

  • synchronized 修饰实例方法,相当于是对类的实例(this)进行加锁,进入同步代码前需要获得当前实例的锁
1
2
3
java复制代码public synchronized void test() {
//...
}
  • synchronized 修饰代码块,相当于是给对象(syncObject)进行加锁,在进入代码块前需要先获得对象的锁
1
2
3
4
5
6
java复制代码public void test() {
synchronized(syncObject) {
//允许访问控制的代码
}
// 其它操作
}
  • synchronized 修饰静态方法,相当于是对类(LockTest.class)进行加锁
1
2
3
4
5
java复制代码public class LockTest {
public synchronized static void test() {
//...
}
}

对于被锁的目标对象而言,锁是具有排他性的,也就是同一个对象上的多个带锁方法,同一时刻只有1个线程可以抢到锁,其余都会被阻塞住。比如下面的代码,线程A和线程B分别同时请求method1和method2,虽然调用的是不同的方法,但是两个线程其实是在争夺同一把锁:

1
2
3
4
5
6
7
8
java复制代码public class LockTest {
public synchronized void method1() {
// ...
}
public synchronized void method2() {
// ...
}
}

image.png

由于synchronized属于JVM关键字,属于一种比较重量级的锁。在JDK中还提供了个Lock类,提供了众多不同类型的锁,供各种不同场景诉求使用。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码final Lock lock = ...;
public void test() {
lock.lock();
try{
// ...
}catch(Exception ex){
// ...
}finally{
// ...
lock.unlock();
}
}

与synchronized不同,使用Lock的时候需要特别注意最后一定要可靠的释放掉占用的锁。

到这里,再测试会发现,多线程并发执行,数据结果也对,似乎是没什么问题——但是这样真的就结束了吗?

如果并发编程仅仅就这么点内容,那显然对不上它在编码界的地位。我们接着往下看。

死锁——不期而遇的小惊吓

经过前面的内容,我们知道了使用多线程的方式来实现并发处理,也知晓了可以通过加锁的方式来保证对共享数据编辑的顺序性与准确性。而加了锁之后稍不留神间,也许就会出现死锁。

image.png

一个线程A已经持有一个锁的情况下同时又去请求调用另一个加锁的对象或者代码块,而这个被请求的对象又被另一个线程B所持有,而这个线程B,又恰好在等待此时被线程A所持有的加锁资源或代码块,于是两个线程都在沉默中无限等待下去,便会出现死锁。

看一个实际业务场景:

一个运维管理系统,用于维护虚拟机资源以及部署的业务进程信息,且支持按照虚拟机维度和业务进程维度进行分别查看相关信息。即:

  1. 查看虚拟机VM信息,需要一并获取到上面部署的Process信息
  2. 查看Process信息,需要一并获取其所位于的虚拟机的信息。

假定基于SpringBoot框架进行代码实现,DeployedProcessService与VmService实例由Spring框架进行托管,为单例对象,然后彼此自动注入对方实例。假定由于业务逻辑需要,对两个服务类的执行方法进行了加锁处理。部署进程管理服务DEMO代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@Service
public class DeployedProcessService {
@Autowired
VmService vmService;

public synchronized void manageDeployedProcessInfo() {
// 获取进程信息
collectProcessInfo();
// 获取进程所在VM信息
vmService.manageVmInfo(this);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@Service
public class VmService {
@Autowired
DeployedProcessService deployedProcessService;

public synchronized void manageVmInfo() {
// 获取此VM基础信息
collectVmBasicInfo();
// 获取此VM上已部署的进程信息
deployedProcessService.manageDeployedProcessInfo(this);
}
}

我们使用两个独立进程同时分别去查询VM信息以及Process信息,模拟并发操作的场景,会发现永远等不到结果。为啥呀?因为死锁了!

我们可以通过jstack命令来看下此时的JVM内线程堆栈情况,会发现有提示Found one Java-level deadlock,然后可以看到死锁的堆栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码Found one Java-level deadlock:
=============================
"ForkJoinPool.commonPool-worker-2":
waiting to lock monitor 0x000000001cf532b8 (object 0x000000076c29bf28, a com.veezean.skills.lock.VmService),
which is held by "ForkJoinPool.commonPool-worker-1"
"ForkJoinPool.commonPool-worker-1":
waiting to lock monitor 0x000000001fce9f88 (object 0x000000076c29f460, a com.veezean.skills.lock.DeployedProcessService),
which is held by "ForkJoinPool.commonPool-worker-2"

Java stack information for the threads listed above:
===================================================
"ForkJoinPool.commonPool-worker-2":
at com.veezean.skills.lock.VmService.manageVmInfo(VmService.java:14)
- waiting to lock <0x000000076c29bf28> (a com.veezean.skills.lock.VmService)
at com.veezean.skills.lock.DeployedProcessService.manageDeployedProcessInfo(DeployedProcessService.java:19)
- locked <0x000000076c29f460> (a com.veezean.skills.lock.DeployedProcessService)
at com.veezean.skills.lock.Main.lambda$main$1(Main.java:19)
"ForkJoinPool.commonPool-worker-1":
at com.veezean.skills.lock.DeployedProcessService.manageDeployedProcessInfo(DeployedProcessService.java:15)
- waiting to lock <0x000000076c29f460> (a com.veezean.skills.lock.DeployedProcessService)
at com.veezean.skills.lock.VmService.manageVmInfo(VmService.java:18)
- locked <0x000000076c29bf28> (a com.veezean.skills.lock.VmService)
at com.veezean.skills.lock.Main.lambda$main$0(Main.java:18)

Found 1 deadlock.

关于死锁的产生原因,网上或者书中给出的答案无外乎就是说如下四个原因要同时成立,就会死锁:

  1. 互斥
  2. 占有并等待
  3. 非抢占
  4. 循环等待

不知道大家看到上面这个解释是啥感觉?懂还是不懂?反正我的经历是:在我懂之后,看这4点中的每一点都很在理;而我不懂时,我依旧不知道啥原因导致的死锁。其实,用白话解释死锁的产生原因,就是两个或者多个线程各自拿到了一个锁,然后自己依赖别人的锁,别人依赖自己的锁,然后彼此都在相互等待,永远没有办法等到。

那么应该如何解决呢?

还是以上面代码为例,一个最简单的方式,就是两个Service类的加锁方法不要相互调用,各自Service类中独立实现所有逻辑即可。

小提示:

一个好的经验,就是加锁的方法嵌套调用另一个加锁的方法时,多留个心眼,看看会不会出现相互依赖或者循环依赖的情况。

锁优化思想——降低锁的影响

规避了可能存在的死锁问题之后,另一个问题又出现在我们面前——性能。我们采用多线程并发编程的初衷,是为了尽可能的提升整体的处理性能,但是加锁之后,加锁的地方反而成为了整个并发处理的一个堵点,导致整个多线程并发的效果大打折扣。

image.png

所以,如何降低锁对多线程并发处理的影响,成为飘在程序员面前的一团新的乌云。为此也衍生出了多种处理与应对策略,比如_降低锁的范围以减少锁持有时间_、缩小锁粒度以降低夺锁竞争、_利用读写锁减少加锁场景_等等。

降低锁范围

这个其实很好理解,因为加锁范围越大,意味着持锁执行的时间就会越久,那么其他线程阻塞等待的时间就会越久,这样整个系统的堵点就会越发明显。而如果能够将一些并不需要放到同步锁内执行的逻辑放到外部去并行执行,这样就会降低锁内逻辑的处理时长,其余线程阻塞等待时间也就会缩短。

image.png

举个例子。假如现在有个更新文章内容的需求,其处理逻辑如下:

  1. 校验当前用户是否有权更新
  2. 校验文章内容重复度
  3. 检查文章中是否有违禁词
  4. 更新到数据库中
  5. 加载到ES中

为了保证并发更新操作的准确性,对方法添加synchronized同步锁,保证多线程顺序执行:

1
2
3
4
5
6
7
java复制代码public synchronized void updateArticle() {
verifyAuthorInfo();
checkArticleDuplication();
checkBlackWords();
saveToDb();
loadToEs();
}

但是实际分析下,其实几个操作其实只有一个环节需要做同步锁处理,其余的操作其实并不会有任何的同步问题,因此我们按照缩小锁范围的优化策略,可以将synchronized锁范围缩小:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public void updateArticle() {
verifyAuthorInfo();
checkArticleDuplication();
checkBlackWords();
saveToDb();
loadToEs();
}

private synchronized void saveToDb() {
// ...
}

缩小锁粒度

锁的粒度越大,多线程请求的时候对锁的竞争压力越大,对性能的影响越大。如果将锁的粒度拆分小一些,这样同时请求到同一把锁的概率就会降低,这样线程间争夺锁的竞争压力就会降低。

可以看下下面的示意图,4个线程请求同一锁时,其中1个线程可以抢到锁,其余三个线程将处于等待;而将锁拆分为3个子锁的时候,这样4个线程中只有1个线程处于等待:

image.png

上面演示的就是分段锁的概念。在JAVA7之前,面试的时候经常会遇到的一个问题就是ConcurrentHashMap与HashTable都是线程安全的,为啥ConcurrentHashMap的性能上会更好些呢?其实就是因为ConcurrentHashMap使用了分段锁(Segment)的方式实现的:

image.png

⭐️补充一下⭐️

上面为啥要强调是JAVA7之前呢?因为JAVA7开始,ConcurrentHashMap的线程安全策略变了,改为了基于CAS的策略了。

细化锁场景

对于同一个共享数据的各种操作,很多时候并不是所有多线程操作都会出数据错乱问题,一般情况下只有写操作才会改变数据的内容,而多个线程同时执行读取操作的时候并不会对数据产生影响,所以这个_读取的场景其实无需和写操作使用相同的同步锁逻辑_。所以为了满足此场景,出现了读写锁。

image.png

读写锁的特点就是,针对读操作和写操作,提供了不同的加锁同步策略,具体而言:

  1. 读读不互斥
  2. 读写互斥
  3. 写写互斥

在 Java 中,读写锁是使用 ReentrantReadWriteLock 类来实现的,它提供了lock方法进行加锁、unlock方法进行解锁。其中:

  • ReentrantReadWriteLock.ReadLock 表示读锁。
  • ReentrantReadWriteLock.WriteLock 表示写锁。

代码示例如下。 创建读写锁,然后通过readLock和writeLock方法,可以分别获取到读锁和写锁:

1
2
3
4
5
6
java复制代码// 创建读写锁
final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
// 获得读锁
final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
// 获得写锁
final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

在读取操作的场景,直接使用读锁,使用完成后需要可靠释放锁:

1
2
3
4
5
6
7
8
9
java复制代码public String readObject() {
// 读锁使用
readLock.lock();
try {
// 业务代码...
} finally {
readLock.unlock();
}
}

在写操作的场景使用写锁,使用完成后同样需要可靠释放锁:

1
2
3
4
5
6
7
8
9
java复制代码public void writeObject() {
// 写锁使用
writeLock.lock();
try {
// 业务代码...
} finally {
writeLock.unlock();
}
}

其它策略

除了上述介绍的各种锁优化策略,还有很多不同类型的锁,整体思路大体相同,此处不再展开描述,具体可以参见这篇文章:《不可不说的JAVA“锁”事》:

image.png

无锁胜有锁——就是要站着还把钱挣了

为了保证多线程的数据安全,我们引入了同步锁;为了降低同步锁的影响,我们绞尽脑汁去降低锁竞争几率。但是勤劳的程序员永远不会满足眼前的结果、不然头顶也不会这么早的锃光瓦亮。于是,一个灵魂拷问又飘了出来:能不能既使用多线程并发处理、又不用加同步锁?

image.png

于是乎,一些无锁解决方案开始在某些特定的并发场景内崭露头角。

ThreadLocal空间换时间

很多时候,编码世界汇总对程序性能的优化,无外乎是时间与空间的权衡。当系统更关注服务的处理响应时长,就会使用一些缓存的策略,降低CPU的重复计算,以此来提升性能。

回到我们多线程的场景,为了保证多个线程对同一个共享内存对象的访问安全,所以通过同步锁的方式来保证串行访问,这样就会造成CPU的排队等待,性能受阻。那么,如果各个内存不去访问这个统一的共享对象,而是访问自己独享的对象,这样不就互不干扰、无需阻塞等待了吗?

比如下面图中的收费站场景,多条路最后需要经由同一个收费站,所以导致收费站这里会出现堵塞。而如果每条路都建一个自己的收费站,这样就有效避免了堵塞的状况。

image.png

image.png

仿照相同的原理,ThreadLocal便出现了。它通过冗余副本的方式,使得某个内存共享对象在各个线程上都有自己的拷贝副本。在尝试去了解ThreadLocal结构与原理前,可以先看下ThreadLocal的set方法实现源码:

1
2
3
4
5
6
7
8
java复制代码public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

翻译成白话文,先获取到当前线程信息,然后获取到当前线程对应的ThreadLocalMap对象,然后将当前对象以及要存储的内容值存到Map中。也就是说:ThreadLocal只是一个方法封装,具体的数据实际存储在ThreadLocalMap中,而这个ThreadLocalMap是每个线程都有自己专属副本,里面存储着这个线程执行过程中使用的所有ThreadLocal对象以及对应数值(代码里面可能会new多个不同的ThreadLocal对象,比如有的用于存储当前用户,有的用于存储当前token信息之类的)。

image.png

从ThreadLocal的实现原理中,我们可以发现_其适用的场景是有限的_,即只适用于需要在单个线程内全局共享的场景,而不适用于需要在多个线程间做数据交互共享的场景。

⭐️适用场景举例⭐️**:**

一个SpringBoot构建的后端服务系统,对外以Controller方式提供诸多Restful接口方法供客户端调用。客户端调用的时候会携带token信息,然后鉴权逻辑中根据token获取到具体用户信息并缓存到内存中,后续的业务处理逻辑中有多处会需要获取该用户信息。

这是ThreadLocal使用的一个典型场景,在通过token鉴权完成后,将用户信息设置到ThreadLocal对象中,这样后续所有需要用的地方,直接从ThreadLocal中获取就行了。

为了方便后续使用,我们先封装一个工具类,提供些静态方法,便于对ThreadLocal进行操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class CurrentUserHolder{
private static final ThreadLocal<UserDetail> CURRENT_USER = ThreadLocal.withInitial(() -> null);

public static void cacheUserDetail(UserDetail userDetail) {
CURRENT_USER.set(userDetail);
}

public static UserDetail getCurrentUser() {
CURRENT_USER.get();
}

public static void clearCache() {
CURRENT_USER.remove();
}
}

在业务处理开始之前先统一设置下用户的缓存信息。因为是基于SpringBoot项目来讲解,所以我们实现一个HandlerInyerceptor的实现类,并在preHandle方法中根据token获取到用户详情并缓存到ThreadLocal中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public class AuthorityInterceptor implements HandlerInterceptor {
private static final ThreadLocal<UserDetail> CURRENT_USER = ThreadLocal.withInitial(() -> null);

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
log.info("request IN, url: {}", request.getRequestURI());
try {
UserDetail userDetail = userAuthService.authUser(request.getHeader("token"));
// 校验通过,缓存用户信息
CurrentUserHolder.cacheUserDetail(userDetail);
return true;
} catch (Exception e) {
// 校验没通过,清理线程数据
CurrentUserHolder.clearCache();
return false;
}
}
}

因为鉴权通过之后,会将当前的用户信息添加到缓存中,并进入到后续的业务实际处理代码中,所以业务处理的时候如果需要获取当前登录用户信息的时候,可以直接从CurrentUserHolder中获取即可。

1
2
3
4
java复制代码public void collectBookToMySpace(Book book) {
UserDetail user = CurrentUserHolder.getCurrentUser();
// 其他逻辑省略
}

借助ThreadLocal可以让我们实现在线程内部共享对象,以此规避多线程间的同步等待处理,但是使用完毕之后,还需要保证清除掉当前线程的缓存数据值。为什么要这么做呢?拿线程池举个例子:

既然是为每个线程拷贝一份独立的副本,对于同一个线程而言拿到的数据是同一个,那么对于使用线程池来处理多任务的场景,线程都是重复利用的,这样会导致同一个线程中正在处理的任务可能会拿到上一个任务设置的共享值。对于业务处理而言可能会得到非预期结果。

当然,除了可能会导致业务处理的时候前后任务缓存数据错乱,使用完毕不清理缓存,有些时候还容易导致内存泄漏的问题。所以编码的时候、尤其涉及内存资源使用的时候,用完回收始终会是一个好习惯。

⭐️可靠清除线程副本⭐️

既然知道在使用完成之后需要可靠的清理掉当前线程的ThreadLocal副本数据,但是对于一些流程比较长、或者逻辑比较复杂的系统,其线程任务的退出分支可能有很多条,那么怎么样才能做到可靠清理、避免有分支遗漏呢?

  1. 如果是自己实现的线程池或者线程分发操作,在子线程的调用顶层位置通过try...finally...包裹调用逻辑,并在finally中进行释放操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public void testReleaseThreadLocalSafely() {
threadPool.submit(() -> {
try {
// 设置token信息
TOKEN.set("123456");
// 执行业务处理操作
mockServiceOperations();
} finally {
// finally分支中可靠清除当前线程的ThreadLocal副本
TOKEN.remove();
}
});
}
  1. 基于一些框架系统实现的场景,比如SpringBoot项目,可以定制个Interceptor并在afterCompletion等退出前回调方法中,添加上对应的清理逻辑。
1
2
3
4
java复制代码@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) {
CurrentUserHolder.clearCurrentThreadCache();
}

volatile保证可见性

与synchronized保证数据同步处理的原理不一样,volatile主要解决的是数据在多线程之间的可见性问题,但是不保证数据操作的原子性。volatile用于修饰变量,可以保证每个共享此变量数据的线程都可以第一时间拿到此值的真实值。

image.png

当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

但是因为它不保证原子操作,所以如果有多个线程同时来修改变量的值时,还是可能会出现问题。所以,volatile适合那种单个线程去修改值内容,但是多个线程会共享读取变量结果的场景。

image.png

比如项目代码中,我们需要支持系统配置属性的动态变更,我们可以将系统参数使用volatile修饰,然后使用固定一个线程进行系统属性值的维护,其余业务线程负责从内存中读取即可。

发布订阅模式

在并发编程中使用发布订阅模式能够解决绝大多数并发问题。

多线程协同配置的场景下,可以借助MQ实现发布订阅模式,可以保证每个任务都分配给不同的消费者进行处理,这样就不会出现重复处理的问题、也减少了线程或者进程间资源争夺的风险,正可谓是“无锁胜有锁、四两拨千斤”的典型。

image.png

对于MQ的选型,如果是单进程内多线程间的使用,可以使用BlockingQueue来实现,而用于分布式系统内时,可以选用一些消息队列中间件,比如RabbitMQ、Kakfa等。

CAS乐观锁策略

所谓CAS,也即Compare And Swap,也即在对数据执行写操作前,先比较下数据是否有变更,没有变更的情况下才去执行写操作,否则重新读取最新记录并重新执行计算后,再执行比对操作,直到数据写入完成。CAS是一种典型的乐观锁策略,其与常规的加同步锁的处理策略有很大的不同,属于一种比较经典的无锁机制:

image.png

并发场景对公共存储(比如MySQL)中的数据进行更新的时候,经常会需要考虑并发更新某个记录的情况,尤其是一些界面编辑更新的场景更是常见。这个场景下使用CAS机制可以有效解决问题。

先看个问题场景:

有个需求任务跟踪管理系统,团队内的成员可以编辑团队内的待办需求事项的进展描述,如果团队内有两个人都打开了某一个需求页面进行编辑进展说明,那么第一个人改动完成存储的内容,会被第二个人保存改动时直接覆盖掉。

image.png

使用CAS的思路来解决上面场景提及的更新覆盖问题,我们可以对DB中的记录数据增加一个version字段,更新的时候必须保证version字段值与自己最初拿到的version值一致时才能更新成功,同时在每次update的时候更新下version字段,这样问题就解决啦,看下过程:

image.png

代码实现起来也很简单:

1
2
3
4
5
6
7
java复制代码public void updateItem(Item  item) {
int updateResult = updateContentByIdAndVersion(item.getContent(), item.getId(), item.getVersion());
if (updateResult == 0) {
// 没有更新成功任何记录,说明version比对失败已经有别人更新了
// 要么放弃处理,要么重试
}
}

CAS始终按照无锁的策略进行数据的处理、处理失败则重试或放弃。在竞争不是很激烈的并发场景下,可以有效的提升整体的处理效率,因为大部分的场景下都会执行成功,只有在少量的请求出现并发冲突的时候,才会进入自旋重试。但当竞争很激烈的场景下,会导致写入操作高频率失败进入自旋,这要会大大的浪费CPU资源,且因为自旋其实就是线程不停的循环,所以大量自旋可能会使得CPU的占用比较高。

image.png

补充说明:

在单进程内的多线程间使用CAS机制保证并发的时候,需要结合volatile一起使用,以此来保证原子性与可见性。

另外,我们在前面有提到JAVA7之前ConcurrentHashMap使用的是分段锁的技术,而从JAVA7之后,ConcurrentHashMap线程安全保护的实现逻辑是改为了CAS+synchronized的方式来实现,以此来获取更好的性表现。

分布式锁——跨越进程的相逢

前面介绍了单进程内的一些多线程高并发场景的应对方案。但高并发的场景,除了单线程内的多线程间的并发之外,还有分布式系统集群内的多个进程之间的并发。所以分布式锁应运而生。

举个例子:

数据库有一张“热议话题”表,表中每条记录有个“当前热度”字段,热度计算服务需要每隔5分钟执行一次计算,然后更新表中每条记录的热度字段。

为了保证系统的高可用,热度计算服务部署了多个进程节点,由定时器触发,每隔5分钟计算一次。

image.png

分布式锁的实现,有多种方式,比较常见的是基于Redis或者MySQL来实现。分布式锁在实现以及使用的时候,需要关注几个要点;

  • 客户端请求锁的整体操作需要是个原子操作,即需要保证锁分配结果的唯一性
  • 客户端获取到锁之后进行自身业务逻辑处理,处理完成之后必须要主动释放锁(需要注意判断下是否是自己所持有的锁)
  • 锁要有兜底退出机制,防止某个客户端获取到锁之后出现宕机等异常情况,导致锁被持有后无法释放,其它客户端也无法继续申请

image.png

比如基于Redis实现分布式锁的时候,使用示意如下:

1
2
3
4
5
6
7
8
9
java复制代码// 获取锁
public boolean accuireLock(String lockName) {
return stringRedisTemplate.opsForValue().setIfAbsent(lockName, "", 1L, TimeUnit.MINUTES);
}

// 释放锁
public void releaseLock(String lockName) {
stringRedisTemplate.delete(lockName);
}

上面代码中,简单的使用setNx命令来实现分布式锁的申请,又设置了redis的超时时间,一旦在设定的时间内依旧没有主动释放锁,则redis将主动释放锁,供其余客户端再来请求。

在上面归纳的分布式锁实现与使用的注意要点中,在提及业务处理完成之后要主动释放锁的时候,有特别补充了一个要求:需要判断下是否是自己的锁,只能释放自己的锁!为什么一定要强调这一点呢?以上述代码为例,看一种可能的情况:

image.png

从上图可以看出,Client-1申请到了_锁1_,但是Client-1执行超时导致_锁1_被强制释放掉了,而Client-2随后获取到了_锁2_并开始执行处理逻辑。此时Client-1的任务终于执行完成了,然后去释放了锁(Client-1自己不知道自己超时,还是按照正常逻辑去释放锁),结果_Client-3_此时又申请到了_锁3_,然后开始执行自己的任务。这个时候就会出现了Client-2和Client-3同时执行的异常情况了。

整个问题出现的原因就是释放锁的时候没有校验是否是自己的锁,所以出现了越权释放了别人的锁的情况。为了避免此情况的发生,我们对前面的分布式锁实现使用逻辑稍加改动即可:

首先是申请分布式锁的时候,可以生成个随机UUID作为锁的value值,如果申请成功,则直接返回此锁的UUID唯一标识:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码/**
* 获取锁,如果获取成功,则返回锁的value值(UUID随机)
*/
public String accuireLock(String lockName) {
String uuid = UUID.randomUUID().toString();
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockName, uuid, 1L,
TimeUnit.MINUTES);
if (result == null || !result) {
throw new RuntimeException("获取锁失败");
}
return uuid;
}

锁释放的时候,需要同时提供锁名称与锁的唯一UUID标识值,先根据锁名称尝试获取下已存在的锁,然后比对下锁value值是否一致,如果一致,则表名当前的锁是自己锁持有的这把锁,然后将其释放即可:

1
2
3
4
5
6
7
8
9
10
java复制代码/**
* 释放锁,先比对锁value一致,才会释放
*/
public void releaseLock(String lockName, String lockUuid) {
String lockValue = stringRedisTemplate.opsForValue().get(lockName);
if (!StringUtils.equals(lockValue, lockUuid)) {
throw new RuntimeException("锁释放失败,锁不存在");
}
stringRedisTemplate.delete(lockName);
}

当然啦,我们这里举例是使用的Redis的setNx命令来实现的,此实现可以轻松的应对大部分的使用场景。但是,上述的释放锁实现代码中可以看出,由于获取锁内容、比对锁内容、释放锁内容三个操作是独立分开的,存在无法保证操作原子性的弊端。如果项目的要求级别较高,可以考虑使用LUA脚本封装为原子命令操作来解决,或者使用redis官方提供的redission来实现。

补充:并发与并行

本文主要讨论了多线程并发编程相关的内容,提到并发,往往还有个容易混淆的概念,叫并行。关于并行的具体介绍与实现策略,以及并发与并行的详细区别,可以参见我的另一个文档《JAVA基于CompletableFuture的流水线并行处理深度实践,满满干货》,此处不述。

综合而言:

  1. 如果业务处理逻辑是CPU密集型的操作,优先使用基于线程池实现并发处理方案(可以避免线程间切换导致的系统性能浪费)。
  2. 如果业务处理逻辑中存在较多需要阻塞等待的耗时场景、且相互之间没有依赖,比如本地IO操作、网络IO请求等等,这种情况优先选择使用并行处理策略(可以避免宝贵的线程资源被阻塞等待)。

总结

好啦,关于多线程并发场景常见问题的相关应对策略,这里就探讨到这里啦。那么看到这里,相信您应该有所收获吧?那么你是否有实际应对过多线程并发场景的开发呢?那你是如何处理的呢?是否有发现过什么问题呢?评论区一起讨论下吧、我会认真对待您的每一个评论~~

此外:

  • 关于本文中涉及的演示代码的完整示例,我已经整理并提交到github中,如果您有需要,可以自取:github.com/veezean/Jav…

我是悟道,聊技术、又不仅仅聊技术~

如果觉得有用,请点赞 + 关注让我感受到您的支持。也可以关注下我的公众号【架构悟道】,获取更及时的更新。

期待与你一起探讨,一起成长为更好的自己。


我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

vite 30 都发布了,经常初始化 vite 项目,却不

发表于 2022-07-28
  1. 前言

大家好,我是若川。为了能帮助到更多对源码感兴趣、想学会看源码、提升自己前端技术能力的同学。我倾力持续组织了一年每周大家一起学习200行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。

想学源码,极力推荐关注我写的专栏(目前是掘金专栏关注人数第一,3.6K+人)《学习源码整体架构系列》 包含jQuery、underscore、lodash、vuex、sentry、axios、redux、koa、vue-devtools、vuex4、koa-compose、vue 3.2 发布、vue-this、create-vue、玩具vite等20余篇源码文章。

本文项目,欢迎 star 和克隆调试学习。git clone https://github.com/lxchuan12/vite-analysis.git

早在2021年10月,我写了Vue 团队公开快如闪电的全新脚手架工具 create-vue,未来将替代 Vue-CLI,才300余行代码,学它!,备受好评。当时 create-vue 还没有配置 eslint 等,现在已经比较完善了。

这是源码共读中第九期。有读者写了最新 create-vue 的源码解读。

我们知道 vite 3.0 发布了。什么?你不知道?想起有好友问:如何关注前端新技术、新热点等,我的回答是关注相关的 Github 和 Twitter,或者关注我的【公众号@若川视野】也可以啊。

你一般会很开心的 npm create vite@lastest 初始化一个 vite 项目。

那么你知道它的原理是什么吗?

今天这篇文章就来带领大家一起学习其原理,源码400行不到。

  1. npm init && npm create

npm init 文档有写。create 其实就是 init 的一个别名。

也就是说 npm create vite@lastest 相当于 => npx create-vite@lastest,latest 是版本号,目前最新版本可以通过以下命令查看。

1
2
3
bash复制代码npm dist-tag ls create-vite
# 输出
# latest: 3.0.0

接着我们克隆 vite 项目,调试 packages/create-vite,分析其源码实现。

  1. 克隆项目 && 调试源码

之前文章写过,新手向:前端程序员必学基本技能——调试JS代码,这里就不赘述。

可以直接克隆我的项目调试。同时欢迎 star 一下。
看开源项目,一般先看 README.md 和相应的 CONTRIBUTING.md。

1
2
3
4
5
6
7
sh复制代码git clone https://github.com/lxchuan12/vite-analysis.git
cd vite-analysis/vite2
# npm i -g pnpm
pnpm install
# 在这个 index.js 文件中断点
# 在命令行终端调试
node vite2/packages/create-vite/index.js

贡献文档中也详细写了如何调试。

调试截图:

调试截图

控制台输出:

控制台输出

最终生成的文件:
最终生成的文件

顺便提下我是如何保持 vite 记录的,其实用的git subtree。

1
2
3
4
bash复制代码# 创建
git subtree add --prefix=vite2 https://github.com/vitejs/vite.git main
# 更新
git subtree pull --prefix=vite2 https://github.com/vitejs/vite.git main

找到路径,packages/create-vite 看 package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
json复制代码{
"name": "create-vite",
"version": "3.0.0",
"type": "module",
"bin": {
"create-vite": "index.js",
"cva": "index.js"
},
"main": "index.js",
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
}

type 类型指定为 module 说明是 ES Module。
bin 可执行命令为 create-vite 或 别名 cva。
我们可以知道主文件 index.js。
代码限制了较高版本的Nodejs。

接着我们调试来看这个 index.js 文件。

  1. 主流程 init 函数拆分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
js复制代码// 高版本的node支持,node 前缀
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

// 解析命令行的参数 链接:https://npm.im/minimist
import minimist from 'minimist'
// 询问选择之类的 链接:https://npm.im/prompts
import prompts from 'prompts'
// 终端颜色输出的库 链接:https://npm.im/kolorist
import {
blue,
cyan,
green,
lightRed,
magenta,
red,
reset,
yellow
} from 'kolorist'

// Avoids autoconversion to number of the project name by defining that the args
// non associated with an option ( _ ) needs to be parsed as a string. See #4606
const argv = minimist(process.argv.slice(2), { string: ['_'] })
// 当前 Nodejs 的执行目录
const cwd = process.cwd()

// 主函数内容省略,后文讲述
async function init() {}
init().catch((e) => {
console.error(e)
})

4.1 输出的目标路径

1
2
3
4
5
6
7
8
9
10
js复制代码// 命令行第一个参数,替换反斜杠 / 为空字符串
let targetDir = formatTargetDir(argv._[0])

// 命令行参数 --template 或者 -t
let template = argv.template || argv.t

const defaultTargetDir = 'vite-project'
// 获取项目名
const getProjectName = () =>
targetDir === '.' ? path.basename(path.resolve()) : targetDir

4.1.1 延伸函数 formatTargetDir

替换反斜杠 / 为空字符串。

1
2
3
js复制代码function formatTargetDir(targetDir) {
return targetDir?.trim().replace(/\/+$/g, '')
}

4.2 prompts 询问项目名、选择框架,选择框架变体等

prompts 根据用户输入选择,代码有删减。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
js复制代码let result = {}
try {
result = await prompts(
[
{
type: targetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: (state) => {
targetDir = formatTargetDir(state.value) || defaultTargetDir
}
},
// 省略若干
],
{
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled')
}
}
)
} catch (cancelled) {
console.log(cancelled.message)
return
}
// user choice associated with prompts
// framework 框架
// overwrite 已有目录,是否重写
// packageName 输入的项目名
// variant 变体, 比如 react => react-ts
const { framework, overwrite, packageName, variant } = result

4.3 重写已有目录/或者创建不存在的目录

1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码// user choice associated with prompts
const { framework, overwrite, packageName, variant } = result

// 目录
const root = path.join(cwd, targetDir)

if (overwrite) {
// 删除文件夹
emptyDir(root)
} else if (!fs.existsSync(root)) {
// 新建文件夹
fs.mkdirSync(root, { recursive: true })
}

4.3.1 延伸函数 emptyDir

递归删除文件夹,相当于 rm -rf xxx。

1
2
3
4
5
6
7
8
js复制代码function emptyDir(dir) {
if (!fs.existsSync(dir)) {
return
}
for (const file of fs.readdirSync(dir)) {
fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
}
}

4.4 获取模板路径

有这些模板目录

从模板里可以看出,目前还算是相对简陋的。比如没有配置 eslint prettier 等。如果你想为多个的 vite 项目,自动添加 eslint prettier。这里推荐vite-pretty-lint,为这个库我出了的源码共读第35期,还有别人参与后写的不错的文章如何为前端项目一键自动添加eslint和prettier的支持。

1
2
3
4
5
6
7
8
9
10
js复制代码// determine template
template = variant || framework || template

console.log(`\nScaffolding project in ${root}...`)

const templateDir = path.resolve(
fileURLToPath(import.meta.url),
'..',
`template-${template}`
)

4.5 写入文件函数

1
2
3
4
5
6
7
8
9
10
11
js复制代码const write = (file, content) => {
// renameFile
const targetPath = renameFiles[file]
? path.join(root, renameFiles[file])
: path.join(root, file)
if (content) {
fs.writeFileSync(targetPath, content)
} else {
copy(path.join(templateDir, file), targetPath)
}
}

这里的 renameFiles,是因为在某些编辑器或者电脑上不支持.gitignore。

1
2
3
js复制代码const renameFiles = {
_gitignore: '.gitignore'
}

4.5.1 延伸函数 copy && copyDir

如果是文件夹用 copyDir 拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
js复制代码function copy(src, dest) {
const stat = fs.statSync(src)
if (stat.isDirectory()) {
copyDir(src, dest)
} else {
fs.copyFileSync(src, dest)
}
}

/**
* @param {string} srcDir
* @param {string} destDir
*/
function copyDir(srcDir, destDir) {
fs.mkdirSync(destDir, { recursive: true })
for (const file of fs.readdirSync(srcDir)) {
const srcFile = path.resolve(srcDir, file)
const destFile = path.resolve(destDir, file)
copy(srcFile, destFile)
}
}

4.6 根据模板路径的文件写入目标路径

package.json 文件单独处理。
它的名字为输入的 packageName 或者获取。

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码const files = fs.readdirSync(templateDir)
for (const file of files.filter((f) => f !== 'package.json')) {
write(file)
}

const pkg = JSON.parse(
fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8')
)

pkg.name = packageName || getProjectName()

write('package.json', JSON.stringify(pkg, null, 2))

4.7 打印安装完成后的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'

console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
console.log(` cd ${path.relative(cwd, root)}`)
}
switch (pkgManager) {
case 'yarn':
console.log(' yarn')
console.log(' yarn dev')
break
default:
console.log(` ${pkgManager} install`)
console.log(` ${pkgManager} run dev`)
break
}
console.log()

4.7.1 延伸的 pkgFromUserAgent 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码/**
* @param {string | undefined} userAgent process.env.npm_config_user_agent
* @returns object | undefined
*/
function pkgFromUserAgent(userAgent) {
if (!userAgent) return undefined
const pkgSpec = userAgent.split(' ')[0]
const pkgSpecArr = pkgSpec.split('/')
return {
name: pkgSpecArr[0],
version: pkgSpecArr[1]
}
}

第一句 pkgFromUserAgent函数,是从使用了什么包管理器创建项目,那么就输出 npm/yarn/pnpm 相应的命令。

1
2
3
sh复制代码npm create vite@lastest
yarn create vite
pnpm create vite
  1. 总结

再来回顾下控制台输出:

控制台输出

到此我们就分析完了整体的流程。总体代码行数不多,不到400行。

1
2
3
4
5
6
7
8
json复制代码{
"name": "create-vite",
"version": "3.0.0",
"type": "module",
"engines": {
"node": "^14.18.0 || >=16.0.0"
}
}

从 package.json 中看到,代码限制了较高版本的Nodejs,采用 ES Module,目前未涉及打包编译。

为了保证轻量快速,源码中很多函数都是自己写的。比如校验项目名,有比较出名的 validate-npm-package-name,vue-cli、create-react-app 中就是用的它。比如删除文件和文件夹,也是自己实现。

依赖包很少。只依赖了三个包。
解析命令行的参数 minimist、
询问选择之类的 prompts、
终端颜色输出的库 kolorist

测试用例本文未涉及,感兴趣的小伙伴可以看,路径:vite/packages/create-vite/tests/cli.spec.ts,采用的是 vitest。

读完本文,你会发现日常使用 npm create vite 初始化 vite 项目,create-vite 才不到400行源码。

我们也可以根据公司相关业务,开发属于自己的脚手架工具。

如果觉得 Vite 项目模板不够,还可以自行修改添加,比如vite-pretty-lint这个库,就是一键为多个 vite 项目自动添加 eslint、prettier。

有时我们容易局限于公司项目无法自拔,不曾看看开源世界,而且开源项目源码就在那里,如果真的有心愿意学,是能学会很多的。

很多源码不是我们想象中的那么高深莫测。源码不应该成为我们的拦路虎,而应该是我们的良师益友。这也可以说是我持续组织源码共读活动的原因之一。

本文项目,欢迎 star 和克隆调试学习。git clone https://github.com/lxchuan12/vite-analysis.git


如果看完有收获,欢迎点赞、评论、分享支持。你的支持和肯定,是我写作的动力~

最后可以持续关注我@若川。关注我的公众号@若川视野。欢迎点此扫码加我微信 ruochuan02 交流,参加由公众号@若川视野发起的,每周大家一起学习200行左右的源码共读活动,共同进步。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

还在用开发者工具上传小程序? 快来试试 miniprogra

发表于 2022-07-26
  1. 前言

大家好,我是若川。我倾力持续组织了一年每周大家一起学习200行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(4.3k+人)第一的专栏,写有20余篇源码文章。

  1. 前情回顾

注意:文章是基于 tag v0.7.0 撰写。目前工具是 0.12.0 版本,后续 mini-ci 会持续更新,文章应该暂时不会更新。

本文提到的工具 mini-ci 已开源,求个 star^_^

1
2
3
4
5
6
bash复制代码# 可全局安装
npm i @ruochuan/mini-ci -g
mini-ci -h
# 也可以不全局安装
npx @ruochuan/mini-ci -h
# 查看帮助信息,或者查看文档:https://github.com/lxchuan12/mini-ci.git,

估计有很多开发小程序的同学,还在使用微信开发者工具上传小程序。如果你是,那么这篇文章非常适合你。如果不是,同样也很适合你。

早在 2021 年 08 月,我写过一篇文章 Vue 3.2 发布了,那尤雨溪是怎么发布 Vue.js 的?

Vue 2.7 如何发布跟Vue 3.2这篇文章类似,所以就不赘述了。

vuejs发布的文件很多代码我们可以直接复制粘贴修改,优化我们自己发布的流程。比如写小程序,相对可能发布频繁,完全可以使用这套代码,配合miniprogram-ci,再加上一些自定义,加以优化。

于是今天我们来开发这样的脚手架工具。

看完本文,你将学到:

1
2
3
4
5
6
bash复制代码1. 如何利用 release-it 提升版本号,自动打 tag,生成 changelog 等
2. npm init 原理
3. 如何写一个脚手架工具
- 如何解析 Nodejs 命令行参数 minimist
- 如何选择单选、多选 enquirer(prompt, MultiSelect)
- 等等

先看看最终开发的效果。

支持的功能

支持的功能

显示帮助信息

显示帮助信息

上传效果

上传效果

  1. 关于为啥要开发这样的工具

关于小程序 ci 上传,再分享两篇文章。

基于 CI 实现微信小程序的持续构建

小打卡小程序自动化构建及发布的工程化实践 虽然文章里不是最新的 miniprogram-ci,但这篇场景写得比较全面。

接着,我们先来看看 miniprogram-ci 官方文档。

  1. miniprogram-ci 官方文档

miniprogram-ci 文档

4.1 上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
js复制代码const ci = require('miniprogram-ci');
(async () => {
const project = new ci.Project({
appid: 'wxsomeappid',
type: 'miniProgram',
projectPath: 'the/project/path',
privateKeyPath: 'the/path/to/privatekey',
ignores: ['node_modules/**/*'],
});
const uploadResult = await ci.upload({
project,
version: '1.1.1',
desc: 'hello',
setting: {
es6: true,
},
onProgressUpdate: console.log,
});
console.log(uploadResult);
})();

4.2 预览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
js复制代码const ci = require('miniprogram-ci');
(async () => {
const project = new ci.Project({
appid: 'wxsomeappid',
type: 'miniProgram',
projectPath: 'the/project/path',
privateKeyPath: 'the/path/to/privatekey',
ignores: ['node_modules/**/*'],
});
const previewResult = await ci.preview({
project,
desc: 'hello', // 此备注将显示在“小程序助手”开发版列表中
setting: {
es6: true,
},
qrcodeFormat: 'image',
qrcodeOutputDest: '/path/to/qrcode/file/destination.jpg',
onProgressUpdate: console.log,
// pagePath: 'pages/index/index', // 预览页面
// searchQuery: 'a=1&b=2', // 预览参数 [注意!]这里的`&`字符在命令行中应写成转义字符`\&`
});
console.log(previewResult);
})();
  1. Taro 小程序插件 @tarojs/plugin-mini-ci

如果使用 Taro 开发的小程序,可以直接使用。

具体如何使用参考文档,我在本文中就不赘述了。

小程序持续集成 @tarojs/plugin-mini-ci

我组织的源码共读第 30 期读的就是这个插件,非常值得学习。@tarojs/plugin-mini-ci 源码解读可以参考 @NewName 的源码文章

我体验下来的感觉有以下几点可以优化。

  • 不支持指定机器人
  • 不支持不打包时上传
  • 不支持官方提供的更多配置
  • 不支持选择多个小程序批量上传等等

如果有时间我可能给 Taro 提 PR,当然不一定会被合并。

  1. uni-app 好像没有提供类似的插件

uni-app 好像没有提供类似的插件。需要自己动手,丰衣足食。

  1. release-it 自动提升版本、打 tag、生成 changelog 等

于是我们自己动手,丰衣足食,写一个工具解决上面提到的问题,支持 Taro 打包后的小程序和 uni-app 打包后的,还有原生小程序上传和预览。

开发小工具之前,先介绍一些好用的工具。

据说很多小伙伴的项目,没有打 tag、没有版本的概念,没有生成 changelog,没有配置 eslint、prettier,没有 commit 等规范。

这些其实不难,commit 规范一般简单做法是安装 npm i git-cz -D,
在package.json 中加入如下脚本。

1
2
3
4
5
json复制代码{
"scripts": {
"commit": "git-cz"
}
}

git 提交时使用 npm run commit 即可,其他就不赘述了。

release-it,自动提升版本号,自动打 tag,生成 changelog 等

release-it 官网仓库

1
2
3
4
bash复制代码npm init release-it
# 选择 .release-it.json 用下面的配置,复制粘贴到 .release-it.json 中。
# 再安装 changelog 插件
npm i @release-it/conventional-changelog -D
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
json复制代码{
"github": {
"release": false
},
"git": {
"commitMessage": "release: v${version}"
},
"npm": {
"publish": false
},
"hooks": {
"after:bump": "echo 更新版本成功"
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": "angular",
"infile": "CHANGELOG.md"
}
}
}

这样配置后,可以 npm run release 执行 release-it 版本。
还支持 hooks 钩子,比如提升版本号后"after:bump": "echo 更新版本成功",更多功能可以查看release-it 官网仓库。

7.1 npm init release-it 原理

为啥 npm init 也可以直接初始化一个项目,带着疑问,我们翻看 npm 文档。

npm init

npm init 用法:

1
2
3
bash复制代码npm init [--force|-f|--yes|-y|--scope]
npm init <@scope> (same as `npx <@scope>/create`)
npm init [<@scope>/]<name> (same as `npx [<@scope>/]create-<name>`)

npm init <initializer> 时转换成 npx 命令:

1
2
3
bash复制代码npm init foo -> npx create-foo
npm init @usr/foo -> npx @usr/create-foo
npm init @usr -> npx @usr/create

看完文档,我们也就理解了:

运行 npm init release-it => 相当于 npx create-release-it

create-release-it

npm init release-it 原理其实就是 npx create-release-it
选择一些配置,生成 .release-it.json 或者 package.json 的 release-it 配置。

再写入命令release 配置到 package.json。

1
2
3
4
5
json复制代码{
"scripts": {
"release": "release-it"
}
}

最后执行 npm install release-it --save-dev
也就是源码里的 await execa('npm', ['install', 'release-it', '--save-dev'], { stdio: 'inherit' });。

这行源码位置

  1. 小程序上传工具实现主流程

需要支持多选,那肯定得遍历数组。

1
2
3
4
5
6
7
8
9
10
js复制代码// 代码只是关键代码,完整的可以查看 https://github.com/lxchuan12/mini-ci/blob/0.7.0/src/index.js
(async () => {
for (const mpConfigItem of mpConfigList) {
try {
const res = await main({});
} catch (err) {
console.log('执行失败', err);
}
}
})();

main 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js复制代码const { green, bold } = require('kolorist');
const step = (msg) => console.log(bold(green(`[step] ${msg}`)));
async function main(options = {}) {
const project = new ci.Project(lastProjectOptions);
if (upload) {
step('开始上传小程序...');
const uploadResult = await ci.upload(lastUploadOptions);
console.log('uploadResult', uploadResult);
}
if (preview) {
step('开始生成预览二维码...');
const previewResult = await ci.preview(lastPreviewOptions);
console.log('previewResult', previewResult);
}
}

8.1 添加功能支持指定参数

使用 minimist 解析命令行参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
js复制代码const getParams = () => {
const params = process.argv.slice(2);
const paramsDefault = {
default: {
robot: 1,
preview: false,
upload: false,
// 空跑,不执行
dry: false,
// 根据配置,单选还是多选来上传小程序
useSelect: false,
useMultiSelect: false,
help: false,
version: false,
},
alias: {
u: 'upload',
r: 'robot',
v: 'version',
d: 'dry',
s: 'useSelect',
m: 'useMultiSelect',
p: 'preview',
h: 'help',
},
};
return require('minimist')(params, paramsDefault);
};

module.exports = {
getParams,
};

8.2 支持读取项目的 package.json 的 version,也支持读取自定义version

kolorist 颜色输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
js复制代码const { red, bold } = require('kolorist');
const getVersion = () => {
let version;
try {
version = require(`${packageJsonPath}/package.json`).version;
} catch (e) {
console.log(e);
console.log(
red(
bold(
'未设置 version , 并且未设置 package.json 路径,无法读取 version',
),
),
);
}
return version;
};

module.exports = {
getVersion,
};

8.3 版本描述 支持指定 git commit hash 和作者

git rev-parse --short HEAD 读取 git 仓库最近一次的 commit hash。

parse-git-config 可以读取 .git/config 配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
js复制代码// const path = require('path');
const { execSync } = require('child_process');
const parseGitConfig = require('parse-git-config');
const getDesc = (projectPath, version) => {
// 获取最新 git 记录 7位的 commit hash
let gitCommitHash = 'git commit hash 为空';
try {
gitCommitHash = execSync('git rev-parse --short HEAD', {
cwd: projectPath,
})
.toString()
.trim();
} catch (e) {
console.warn('获取 git commit hash 失败');
console.warn(e);
}

// 获取项目的git仓库的 user.name
let userName = '默认';
try {
const {
user: { name = '默认' },
} = parseGitConfig.sync({
cwd: projectPath,
path: '.git/config',
});
userName = name;
} catch (e) {
console.warn('获取 .git/config user.name 失败');
console.warn(e);
}

const desc = `v${version} - ${gitCommitHash} - by@${userName}`;
return desc;
};

module.exports = getDesc;

8.4 读取配置 wx.config.js 配置(更推荐)

当前也支持读取 .env 配置。读取 .env 配置,可以采用 dotenv。关于 dotenv 的原理,可以看我之前写过的文章面试官:项目中常用的 .env 文件原理是什么?如何实现?

但 wx.config.js 可以配置更多东西而且更灵活。所以更推荐。

感兴趣的可以研究 vue-cli 是如何读取 vue.config.js 配置的。围绕工作相关的学习,往往收益更大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
js复制代码// 读取 wx.config.js 配置
const loadWxconfig = (cwd) => {
try {
return require(path.join(cwd, 'wx.config.js'));
} catch (e) {
return {
error: '未配置 wx.config.js 文件',
};
}
};

const parseEnv = () => {
const cwd = process.cwd();

let parsed = {};
let wxconfig = loadWxconfig(cwd);
if (wxconfig.error) {
let dotenvResult = require('dotenv').config({
path: path.join(cwd, './.env'),
});

parsed = dotenvResult.parsed;
if (dotenvResult.error) {
throw error;
}
} else {
parsed = wxconfig;
}
// 代码有省略
};

8.5 支持选择多个小程序

我们可以用 enquirer 来实现单选或者多选的功能。以下只是关键代码。
完整代码可以查看 mini-ci/src/utils/getConfig.js 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码// 只是关键代码
const { prompt, MultiSelect } = require('enquirer');
const configPathList = fs.readdirSync(configPath);
const configPathListJson = configPathList.map((el) => {
return require(`${configPath}/${el}`);
});
const { name } = await prompt({
type: 'select',
name: 'name',
message: '请选择一个小程序配置',
choices: configPathListJson,
});
result = configPathListJson.filter((el) => el.name === name);
return result;

8.6 支持多个批量上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
js复制代码// 只是关键代码
const { prompt, MultiSelect } = require('enquirer');
const configPathList = fs.readdirSync(configPath);
const configPathListJson = configPathList.map((el) => {
return require(`${configPath}/${el}`);
});
const multiSelectPrompt = new MultiSelect({
name: 'value',
message: '可选择多个小程序配置',
limit: 7,
choices: configPathListJson,
});

try {
const answer = await multiSelectPrompt.run();
console.log('Answer:', answer);
result = configPathListJson.filter((el) => answer.includes(el.name));
return result;
} catch (err) {
console.log('您已经取消');
console.log(err);
process.exit(1);
}

后续可能接入 CI/CD、接入邮件提醒、接入钉钉、支持可视化操作等等

8.7 更多如何使用可以参考文档

1
2
3
4
5
6
7
bash复制代码# 全局安装 mini-ci 工具,也可以不全局安装
npm i mini-ci -g
# 文档:https://github.com/lxchuan12/mini-ci.git
# 克隆腾讯开源的电商小程序
git clone https://github.com/lxchuan12/tdesign-miniprogram-starter-retail.git
# 切到分支 feature/release-it
git checkout feature/release-it

可以克隆我的另外一个小程序(腾讯开源的电商小程序)。比如 projects 中。

按照微信小程序文档配置小程序密钥等,这样就能上传和预览了。如果没有微信小程序,可以自行免费开通个人的微信小程序。

  1. 总结

通过本文的学习,我们知道了以下知识。

1
2
3
4
5
6
bash复制代码1. 如何利用 release-it 提升版本号,自动打 tag,生成 changelog 等
2. npm init 原理
3. 如何写一个脚手架工具
- 如何解析 Nodejs 命令行参数 minimist
- 如何选择单选、多选 enquirer(prompt, MultiSelect)
- 等等

我相信大家也能够自己动手实现公司类似要求的脚手架工具,减少发版时间,降本提效。

本文提到的工具 mini-ci 已开源,求个 star^_^

1
2
3
4
5
6
bash复制代码# 可全局安装
npm i @ruochuan/mini-ci -g
mini-ci -h
# 也可以不全局安装
npx @ruochuan/mini-ci -h
# 查看帮助信息,或者查看文档:https://github.com/lxchuan12/mini-ci.git,

注意:文章是基于 tag v0.7.0 撰写。目前工具是 0.12.0 版本,后续 mini-ci 会持续更新,文章应该暂时不会更新。


最后可以持续关注我@若川。我倾力持续组织了一年每周大家一起学习200行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。

另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(4.2k+人)第一的专栏,写有20余篇源码文章。包含jQuery、underscore、lodash、vuex、sentry、axios、redux、koa、vue-devtools、vuex4、koa-compose、vue 3.2 发布、vue-this、create-vue、玩具vite、create-vite 等20余篇源码文章。


我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JAVA基于CompletableFuture的流水线并行处

发表于 2022-07-25

大家好,又见面啦。

在项目开发中,后端服务对外提供API接口一般都会关注响应时长。但是某些情况下,由于业务规划逻辑的原因,我们的接口可能会是一个聚合信息处理类的处理逻辑,比如我们从多个不同的地方获取数据,然后汇总处理为最终的结果再返回给调用方,这种情况下,往往会导致我们的接口响应特别的慢。

而如果我们想要动手进行优化的时候呢,就会涉及到串行处理改并行处理的问题。在JAVA中并行处理的能力支持已经相对完善,通过对CompletableFuture的合理利用,可以让我们面对这种聚合类处理的场景会更加的得心应手。

好啦,话不多说,接下来就让我们一起来品尝下JAVA中组合式并行处理这道饕餮大餐吧。

image.png

前菜:先看个实际场景

在开始享用这顿大餐前,我们先来个前菜开开胃。

例如现在有这么个需求:

需求描述:
实现一个全网比价服务,比如可以从某宝、某东、某夕夕去获取某个商品的价格、优惠金额,并计算出实际付款金额,最终返回价格最优的平台与价格信息。

📢这里假定每个平台获取原价格与优惠券的接口已经实现、且都是需要调用HTTP接口查询的耗时操作,Mock接口每个耗时1s左右。

根据最初的需求理解,我们可以很自然的写出对应实现代码:

1
2
3
4
5
6
7
java复制代码public PriceResult getCheapestPlatAndPrice(String product) {
PriceResult mouBaoPrice = computeRealPrice(HttpRequestMock.getMouBaoPrice(product), HttpRequestMock.getMouBaoDiscounts(product));
PriceResult mouDongPrice = computeRealPrice(HttpRequestMock.getMouDongPrice(product), HttpRequestMock.getMouDongDiscounts(product));
PriceResult mouXiXiPrice = computeRealPrice(HttpRequestMock.getMouXiXiPrice(product), HttpRequestMock.getMouXiXiDiscounts(product));
// 计算并选出实际价格最低的平台
return Stream.of(mouBaoPrice, mouDongPrice, mouXiXiPrice). min(Comparator.comparingInt(PriceResult::getRealPrice)) .get();
}

一切顺利成章,运行测试下:

1
2
3
4
5
6
7
8
9
10
11
ini复制代码05:24:54.779[main|1]获取某宝上 Iphone13的价格完成: 5199
05:24:55.781[main|1]获取某宝上 Iphone13的优惠完成: -200
05:24:55.781[main|1]某宝最终价格计算完成:4999
05:24:56.784[main|1]获取某东上 Iphone13的价格完成: 5299
05:24:57.786[main|1]获取某东上 Iphone13的优惠完成: -150
05:24:57.786[main|1]某东最终价格计算完成:5149
05:24:58.788[main|1]获取某夕夕上 Iphone13的价格完成: 5399
05:24:59.791[main|1]获取某夕夕上 Iphone13的优惠完成: -5300
05:24:59.791[main|1]某夕夕最终价格计算完成:99
获取最优价格信息:【平台:某夕夕, 原价:5399, 折扣:0, 实付价:99】
-----执行耗时: 6122ms ------

结果符合预期,功能一切正常,就是耗时长了点。试想一下,假如你在某个APP操作查询的时候,等待6s才返回结果,估计会直接把APP给卸载了吧?

梳理下前面代码的实现思路:

image.png

所有的环节都是串行的,每个环节耗时加到一起,接口总耗时肯定很长。

但实际上,每个平台之间的操作是互不干扰的,那我们自然而然的可以想到,可以通过多线程的方式,同时去分别执行各个平台的逻辑处理,最后将各个平台的结果汇总到一起比对得到最低价格。

所以整个执行过程会变成如下的效果:

image.png

为了提升性能,我们采用线程池来负责多线程的处理操作,因为我们需要得到各个子线程处理的结果,所以我们需要使用 Future来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码public PriceResult getCheapestPlatAndPrice2(String product) {
Future<PriceResult> mouBaoFuture = threadPool.submit(() -> computeRealPrice(HttpRequestMock.getMouBaoPrice(product), HttpRequestMock.getMouBaoDiscounts(product)));
Future<PriceResult> mouDongFuture = threadPool.submit(() -> computeRealPrice(HttpRequestMock.getMouDongPrice(product), HttpRequestMock.getMouDongDiscounts(product)));
Future<PriceResult> mouXiXiFuture = threadPool.submit(() -> computeRealPrice(HttpRequestMock.getMouXiXiPrice(product), HttpRequestMock.getMouXiXiDiscounts(product)));

// 等待所有线程结果都处理完成,然后从结果中计算出最低价
return Stream.of(mouBaoFuture, mouDongFuture, mouXiXiFuture)
.map(priceResultFuture -> {
try {
return priceResultFuture.get(5L, TimeUnit.SECONDS);
} catch (Exception e) {
return null;
}
})
.filter(Objects::nonNull).min(Comparator.comparingInt(PriceResult::getRealPrice)).get();
}

上述代码中,将三个不同平台对应的Callable函数逻辑放入到ThreadPool中去执行,返回Future对象,然后再逐个通过Future.get()接口阻塞获取各自平台的结果,最后经比较处理后返回最低价信息。

执行代码,可以看到执行结果与过程如下:

1
2
3
4
5
6
7
8
9
10
11
ini复制代码05:42:25.291[pool-1-thread-2|13]获取某东上 Iphone13的价格完成: 5299
05:42:25.291[pool-1-thread-3|14]获取某夕夕上 Iphone13的价格完成: 5399
05:42:25.291[pool-1-thread-1|12]获取某宝上 Iphone13的价格完成: 5199
05:42:26.294[pool-1-thread-2|13]获取某东上 Iphone13的优惠完成: -150
05:42:26.294[pool-1-thread-3|14]获取某夕夕上 Iphone13的优惠完成: -5300
05:42:26.294[pool-1-thread-1|12]获取某宝上 Iphone13的优惠完成: -200
05:42:26.294[pool-1-thread-2|13]某东最终价格计算完成:5149
05:42:26.294[pool-1-thread-3|14]某夕夕最终价格计算完成:99
05:42:26.294[pool-1-thread-1|12]某宝最终价格计算完成:4999
获取最优价格信息:【平台:某夕夕, 原价:5399, 折扣:0, 实付价:99】
-----执行耗时: 2119ms ------

结果与第一种实现方式一致,但是接口总耗时从6s下降到了2s,效果还是很显著的。但是,是否还能再压缩一些呢?

基于上面按照平台拆分并行处理的思路继续推进,我们可以看出每个平台内的处理逻辑其实可以分为3个主要步骤:

  1. 获取原始价格(耗时操作)
  2. 获取折扣优惠(耗时操作)
  3. 得到原始价格和折扣优惠之后,计算实付价格

这3个步骤中,第1、2两个耗时操作也是相对独立的,如果也能并行处理的话,响应时长上应该又会缩短一些,即如下的处理流程:

image.png

我们当然可以继续使用上面提到的线程池+Future的方式,但Future在应对并行结果组合以及后续处理等方面显得力不从心,弊端明显:

代码写起来会非常拖沓:先封装Callable函数放到线程池中去执行查询操作,然后分三组阻塞等待结果并计算出各自结果,最后再阻塞等待价格计算完成后汇总得到最终结果。

说到这里呢,就需要我们新的主人公CompletableFuture登场了,通过它我们可以很轻松的来完成任务的并行处理,以及各个并行任务结果之间的组合再处理等操作。我们使用CompletableFuture编写实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public PriceResult getCheapestPlatAndPrice3(String product) {
CompletableFuture<PriceResult> mouBao = CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouBaoPrice(product)).thenCombine(CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouBaoDiscounts(product)), this::computeRealPrice);
CompletableFuture<PriceResult> mouDong = CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouDongPrice(product)).thenCombine(CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouDongDiscounts(product)), this::computeRealPrice);
CompletableFuture<PriceResult> mouXiXi = CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouXiXiPrice(product)).thenCombine(CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouXiXiDiscounts(product)), this::computeRealPrice);

// 排序并获取最低价格
return Stream.of(mouBao, mouDong, mouXiXi)
.map(CompletableFuture::join)
.sorted(Comparator.comparingInt(PriceResult::getRealPrice))
.findFirst()
.get();
}

看下执行结果符合预期,而接口耗时则降到了1s(因为我们依赖的每一个查询实际操作的接口耗时都是模拟的1s,所以这个结果已经算是此复合接口能达到的极限值了)。

1
2
3
4
5
6
7
8
9
10
11
ini复制代码06:01:13.354[ForkJoinPool.commonPool-worker-6|17]获取某夕夕上 Iphone13的优惠完成: -5300
06:01:13.354[ForkJoinPool.commonPool-worker-13|16]获取某夕夕上 Iphone13的价格完成: 5399
06:01:13.354[ForkJoinPool.commonPool-worker-4|15]获取某东上 Iphone13的优惠完成: -150
06:01:13.354[ForkJoinPool.commonPool-worker-9|12]获取某宝上 Iphone13的价格完成: 5199
06:01:13.354[ForkJoinPool.commonPool-worker-11|14]获取某东上 Iphone13的价格完成: 5299
06:01:13.354[ForkJoinPool.commonPool-worker-2|13]获取某宝上 Iphone13的优惠完成: -200
06:01:13.354[ForkJoinPool.commonPool-worker-13|16]某夕夕最终价格计算完成:99
06:01:13.354[ForkJoinPool.commonPool-worker-11|14]某东最终价格计算完成:5149
06:01:13.354[ForkJoinPool.commonPool-worker-2|13]某宝最终价格计算完成:4999
获取最优价格信息:【平台:某夕夕, 原价:5399, 折扣:0, 实付价:99】
-----执行耗时: 1095ms ------

好啦,通过餐前的前菜,大家应该能够看出来串行与并行处理逻辑的区别、以及并行处理逻辑的实现策略了吧?这里我们应该也可以看出CompletableFuture在应对并行处理场景下的强大优势。当然咯,上面也只是小小的窥视了下CompletableFuture功能的冰上一角,下面就让我们一起来深入了解下,享用并消化CompletableFuture这道主菜吧!

主菜:CompletableFuture深入了解

好啦,下面该主菜上场了。

作为JAVA8之后加入的新成员,CompletableFuture的实现与使用上,也处处体现出了函数式异步编程的味道。一个CompletableFuture对象可以被一个环节接一个环节的处理、也可以对两个或者多个CompletableFuture进行组合处理或者等待结果完成。通过对CompletableFuture各种方法的合理使用与组合搭配,可以让我们在很多的场景都可以应付自如。

下面就来一起了解下这些方法以及对应的使用方式吧。

Future与CompletableFuture

首先,先来理一下Future与CompletableFuture之间的关系。

Future

如果接触过多线程相关的概念,那Future应该不会陌生,早在Java5中就已经存在了。

该如何理解Future呢?举个生活中的例子:

你去咖啡店点了一杯咖啡,然后服务员会给你一个订单小票。
当服务员在后台制作咖啡的时候,你并没有在店里等待,而是出门到隔壁甜品店又买了个面包。
当面包买好之后,你回到咖啡店,拿着订单小票去取咖啡。
取到咖啡后,你边喝咖啡边把面包吃了……嗝~

是不是很熟悉的生活场景? 对比到我们多线程异步编程的场景中,咖啡店的订单小票其实就是Future,通过Future可以让稍后适当的时候可以获取到对应的异步执行线程中的执行结果。

上面的场景,我们翻译为代码实现逻辑:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public void buyCoffeeAndOthers() throws ExecutionException, InterruptedException {
goShopping();
// 子线程中去处理做咖啡这件事,返回future对象
Future<Coffee> coffeeTicket = threadPool.submit(this::makeCoffee);
// 主线程同步去做其他的事情
Bread bread = buySomeBread();
// 主线程其他事情并行处理完成,阻塞等待获取子线程执行结果
Coffee coffee = coffeeTicket.get();
// 子线程结果获取完成,主线程继续执行
eatAndDrink(bread, coffee);
}

image.png

编码源于生活、代码中的设计逻辑,很多时候都是与生活哲学匹配的。

CompletableFuture

Future在应对一些简单且相互独立的异步执行场景很便捷,但是在一些复杂的场景,比如同时需要多个有依赖关系的异步独立处理的时候,或者是一些类似流水线的异步处理场景时,就显得力不从心了。比如:

  • 同时执行多个并行任务,等待最快的一个完成之后就可以继续往后处理
  • 多个异步任务,每个异步任务都需要依赖前一个异步任务执行的结果再去执行下一个异步任务,最后只需要一个最终的结果
  • 等待多个异步任务全部执行完成后触发下一个动作执行
  • …

所以呢, 在JAVA8开始引入了全新的CompletableFuture类,它是Future接口的一个实现类。也就是在Future接口的基础上,额外封装提供了一些执行方法,用来解决Future使用场景中的一些不足,对流水线处理能力提供了支持。

image.png

下一节中,我们就来进一步的了解下CompletableFuture的具体使用场景与使用方式。

CompletableFuture使用方式

创建CompletableFuture并执行

当我们需要进行异步处理的时候,我们可以通过CompletableFuture.supplyAsync方法,传入一个具体的要执行的处理逻辑函数,这样就轻松的完成了CompletableFuture的创建与触发执行。

方法名称 作用描述
supplyAsync 静态方法,用于构建一个CompletableFuture<T>对象,并异步执行传入的函数,允许执行函数有返回值T。
runAsync 静态方法,用于构建一个CompletableFuture<Void>对象,并异步执行传入函数,与supplyAsync的区别在于此方法传入的是Callable类型,仅执行,没有返回值。

使用示例:

1
2
3
4
5
6
7
8
java复制代码public void testCreateFuture(String product) {
// supplyAsync, 执行逻辑有返回值PriceResult
CompletableFuture<PriceResult> supplyAsyncResult =
CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouBaoPrice(product));
// runAsync, 执行逻辑没有返回值
CompletableFuture<Void> runAsyncResult =
CompletableFuture.runAsync(() -> System.out.println(product));
}

特别补充:

supplyAsync或者runAsync创建后便会立即执行,无需手动调用触发。

环环相扣处理

在流水线处理场景中,往往都是一个任务环节处理完成后,下一个任务环节接着上一环节处理结果继续处理。CompletableFuture用于这种流水线环节驱动类的方法有很多,相互之间主要是在返回值或者给到下一环节的入参上有些许差异,使用时需要注意区分:

image.png

具体的方法的描述归纳如下:

方法名称 作用描述
thenApply 对CompletableFuture的执行后的具体结果进行追加处理,并将当前的CompletableFuture泛型对象更改为处理后新的对象类型,返回当前CompletableFuture对象。
thenCompose 与thenApply类似。区别点在于:此方法的入参函数返回一个CompletableFuture类型对象。
thenAccept 与thenApply方法类似,区别点在于thenAccept返回void类型,没有具体结果输出,适合无需返回值的场景。
thenRun 与thenAccept类似,区别点在于thenAccept可以将前面CompletableFuture执行的实际结果作为入参进行传入并使用,但是thenRun方法没有任何入参,只能执行一个Runnable函数,并且返回void类型。

因为上述thenApply、thenCompose方法的输出仍然都是一个CompletableFuture对象,所以各个方法是可以一环接一环的进行调用,形成流水线式的处理逻辑:

image.png

期望总是美好的,但是实际情况却总不尽如人意。在我们编排流水线的时候,如果某一个环节执行抛出异常了,会导致整个流水线后续的环节就没法再继续下去了,比如下面的例子:

1
2
3
4
5
6
7
8
java复制代码public void testExceptionHandle() {
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("supplyAsync excetion occurred...");
}).thenApply(obj -> {
System.out.println("thenApply executed...");
return obj;
}).join();
}

执行之后会发现,supplyAsync抛出异常后,后面的thenApply并没有被执行。

那如果我们想要让流水线的每个环节处理失败之后都能让流水线继续往下面环节处理,让后续环节可以拿到前面环节的结果或者是抛出的异常并进行对应的应对处理,就需要用到handle和whenCompletable方法了。

先看下两个方法的作用描述:

方法名称 作用描述
handle 与thenApply类似,区别点在于handle执行函数的入参有两个,一个是CompletableFuture执行的实际结果,一个是是Throwable对象,这样如果前面执行出现异常的时候,可以通过handle获取到异常并进行处理。
whenComplete 与handle类似,区别点在于whenComplete执行后无返回值。

我们对上面一段代码示例修改使用handle方法来处理:

1
2
3
4
5
6
7
8
9
10
java复制代码public void testExceptionHandle() {
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("supplyAsync excetion occurred...");
}).handle((obj, e) -> {
if (e != null) {
System.out.println("thenApply executed, exception occurred...");
}
return obj;
}).join();
}

再执行可以发现,即使前面环节出现异常,后面环节也可以继续处理,且可以拿到前一环节抛出的异常信息:

1
erlang复制代码thenApply executed, exception occurred...

多个CompletableFuture组合操作

前面一直在介绍流水线式的处理场景,但是很多时候,流水线处理场景也不会是一个链路顺序往下走的情况,很多时候为了提升并行效率,一些没有依赖的环节我们会让他们同时去执行,然后在某些环节需要依赖的时候,进行结果的依赖合并处理,类似如下图的效果。

image.png

CompletableFuture相比于Future的一大优势,就是可以方便的实现多个并行环节的合并处理。相关涉及方法介绍归纳如下:

方法名称 作用描述
thenCombine 将两个CompletableFuture对象组合起来进行下一步处理,可以拿到两个执行结果,并传给自己的执行函数进行下一步处理,最后返回一个新的CompletableFuture对象。
thenAcceptBoth 与thenCombine类似,区别点在于thenAcceptBoth传入的执行函数没有返回值,即thenAcceptBoth返回值为CompletableFuture<Void>。
runAfterBoth 等待两个CompletableFuture都执行完成后再执行某个Runnable对象,再执行下一个的逻辑,类似thenRun。
applyToEither 两个CompletableFuture中任意一个完成的时候,继续执行后面给定的新的函数处理。再执行后面给定函数的逻辑,类似thenApply。
acceptEither 两个CompletableFuture中任意一个完成的时候,继续执行后面给定的新的函数处理。再执行后面给定函数的逻辑,类似thenAccept。
runAfterEither 等待两个CompletableFuture中任意一个执行完成后再执行某个Runnable对象,可以理解为thenRun的升级版,注意与runAfterBoth对比理解。
allOf 静态方法,阻塞等待所有给定的CompletableFuture执行结束后,返回一个CompletableFuture<Void>结果。
anyOf 静态方法,阻塞等待任意一个给定的CompletableFuture对象执行结束后,返回一个CompletableFuture<Void>结果。

结果等待与获取

在执行线程中将任务放到工作线程中进行处理的时候,执行线程与工作线程之间是异步执行的模式,如果执行线程需要获取到共工作线程的执行结果,则可以通过get或者join方法,阻塞等待并从CompletableFuture中获取对应的值。

image.png

对get和join的方法功能含义说明归纳如下:

方法名称 作用描述
get() 等待CompletableFuture执行完成并获取其具体执行结果,可能会抛出异常,需要代码调用的地方手动try...catch进行处理。
get(long, TimeUnit) 与get()相同,只是允许设定阻塞等待超时时间,如果等待超过设定时间,则会抛出异常终止阻塞等待。
join() 等待CompletableFuture执行完成并获取其具体执行结果,可能会抛出运行时异常,无需代码调用的地方手动try…catch进行处理。

从介绍上可以看出,两者的区别就在于是否需要调用方显式的进行try…catch处理逻辑,使用代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public void testGetAndJoin(String product) {
// join无需显式try...catch...
PriceResult joinResult = CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouXiXiPrice(product))
.join();

try {
// get显式try...catch...
PriceResult getResult = CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouXiXiPrice(product))
.get(5L, TimeUnit.SECONDS);
} catch (Exception e) {
e.printStackTrace();
}
}

CompletableFuture方法及其Async版本

我们在使用CompletableFuture的时候会发现,有很多的方法,都会同时有两个以Async命名结尾的方法版本。以前面我们用的比较多的thenCombine方法为例:

  1. thenCombine(CompletionStage, BiFunction)
  2. thenCombineAsync(CompletionStage, BiFunction)
  3. thenCombineAsync(CompletionStage, BiFunction, Executor)

从参数上看,区别并不大,仅第三个方法入参中多了线程池Executor对象。看下三个方法的源码实现,会发现其整体实现逻辑都是一致的,仅仅是使用线程池这个地方的逻辑有一点点的差异:

image.png

有兴趣的可以去翻一下此部分的源码实现,这里概括下三者的区别:

  1. thenCombine方法,沿用上一个执行任务所使用的线程池进行处理
  2. thenCombineAsync两个入参的方法,使用默认的ForkJoinPool线程池中的工作线程进行处理
  3. themCombineAsync三个入参的方法,支持自定义线程池并指定使用自定义线程池中的线程作为工作线程去处理待执行任务。

为了更好的理解下上述的三个差异点,我们通过下面的代码来演示下:

  • **用法1: **其中一个supplyAsync方法以及thenCombineAsync指定使用自定义线程池,另一个supplyAsync方法不指定线程池(使用默认线程池)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public PriceResult getCheapestPlatAndPrice4(String product) {
// 构造自定义线程池
ExecutorService executor = Executors.newFixedThreadPool(5);

return
CompletableFuture.supplyAsync(
() -> HttpRequestMock.getMouXiXiPrice(product),
executor
).thenCombineAsync(
CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouXiXiDiscounts(product)),
this::computeRealPrice,
executor
).join();
}

对上述代码实现策略的解读,以及与执行结果的关系展示如下图所示,可以看出,没有指定自定义线程池的supplyAsync方法,其使用了默认的ForkJoinPool工作线程来运行,而另外两个指定了自定义线程池的方法,则使用了自定义线程池来执行。

image.png

  • 用法2: 不指定自定义线程池,使用默认线程池策略,使用thenCombine方法
1
2
3
4
5
6
7
8
9
java复制代码public PriceResult getCheapestPlatAndPrice5(String product) {
return
CompletableFuture.supplyAsync(
() -> HttpRequestMock.getMouXiXiPrice(product)
).thenCombine(
CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouXiXiDiscounts(product)),
this::computeRealPrice
).join();
}

执行结果如下,可以看到执行线程名称与用法1示例相比发生了变化。因为没有指定线程池,所以两个supplyAsync方法都是用的默认的ForkJoinPool线程池,而thenCombine使用的是上一个任务所使用的线程池,所以也是用的ForkJoinPool。

1
2
3
4
5
6
7
ini复制代码14:34:27.815[ForkJoinPool.commonPool-worker-1|12]获取某夕夕上 Iphone13的价格
14:34:27.815[ForkJoinPool.commonPool-worker-2|13]获取某夕夕上 Iphone13的优惠
14:34:28.831[ForkJoinPool.commonPool-worker-2|13]获取某夕夕上 Iphone13的优惠完成: -5300
14:34:28.831[ForkJoinPool.commonPool-worker-1|12]获取某夕夕上 Iphone13的价格完成: 5399
14:34:28.831[ForkJoinPool.commonPool-worker-2|13]某夕夕最终价格计算完成:99
获取最优价格信息:【平台:某夕夕, 原价:5399, 折扣:0, 实付价:99】
-----执行耗时: 1083ms ------

现在,我们知道了方法名称带有Async和不带Async的实现策略上的差异点就在于使用哪个线程池来执行而已。那么,对我们实际的指导意义是啥呢?实际使用的时候,我们怎么判断自己应该使用带Async结尾的方法、还是不带Async结尾的方法呢?

image.png

上面是Async结尾方法默认使用的ForkJoinPool创建的逻辑,这里可以看出,默认的线程池中的工作线程数是CPU核数 - 1,并且指定了默认的丢弃策略等,这就是一个主要关键点。

所以说,符合以下几个条件的时候,可以考虑使用带有Async后缀的方法,指定自定义线程池:

  • 默认线程池的线程数满足不了实际诉求
  • 默认线程池的类型不符合自己业务诉求
  • 默认线程池的队列满处理策略不满足自己诉求

与Stream结合使用的注意点

在我前面的文档中,有细致全面的介绍过Stream流相关的使用方式(不清楚的同学速点👉👉《吃透JAVA的Stream流操作,多年实践总结》了解下啦)。在涉及批量进行并行处理的时候,通过Stream与CompletableFuture结合使用,可以简化我们的很多编码逻辑。但是在使用细节方面需要注意下,避免达不到使用CompletableFuture的预期效果。

需求场景:
在同一个平台内,传入多个商品,查询不同商品对应的价格与优惠信息,并选出实付价格最低的商品信息。

结合前面的介绍分析,我们应该知道最佳的方式,就是同时并行的方式去各自请求数据,最后合并处理即可。所以我们规划按照如下的策略来实现:

image.png

先看第一种编码实现:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public PriceResult comparePriceInOnePlat(List<String> products) {
return products.stream()
.map(product ->
CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouBaoPrice(product))
.thenCombine(
CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouBaoDiscounts(product)),
this::computeRealPrice))
.map(CompletableFuture::join)
.sorted(Comparator.comparingInt(PriceResult::getRealPrice))
.findFirst()
.get();
}

对于List的处理场景,这里采用了Stream方式来进行遍历与结果的收集、排序与返回。看似正常,但是执行的时候会发现,并没有达到我们预期的效果:

1
2
3
4
5
6
7
8
9
10
11
ini复制代码07:37:15.408[ForkJoinPool.commonPool-worker-9|12]获取某宝上 Iphone13黑色的价格完成: 5199
07:37:15.408[ForkJoinPool.commonPool-worker-2|13]获取某宝上 Iphone13黑色的优惠完成: -200
07:37:15.408[ForkJoinPool.commonPool-worker-2|13]某宝最终价格计算完成:4999
07:37:16.410[ForkJoinPool.commonPool-worker-9|12]获取某宝上 Iphone13白色的价格完成: 5199
07:37:16.410[ForkJoinPool.commonPool-worker-11|14]获取某宝上 Iphone13白色的优惠完成: -200
07:37:16.410[ForkJoinPool.commonPool-worker-11|14]某宝最终价格计算完成:4999
07:37:17.412[ForkJoinPool.commonPool-worker-11|14]获取某宝上 Iphone13红色的价格完成: 5199
07:37:17.412[ForkJoinPool.commonPool-worker-9|12]获取某宝上 Iphone13红色的优惠完成: -200
07:37:17.412[ForkJoinPool.commonPool-worker-9|12]某宝最终价格计算完成:4999
获取最优价格信息:【平台:某宝, 原价:5199, 折扣:0, 实付价:4999】
-----执行耗时: 3132ms ------

从上述执行结果可以看出,其具体处理的时候,其实是按照下面的逻辑去处理了:

image.png

为什么会出现这种实际与预期的差异呢?原因就在于我们使用的Stream上面!虽然Stream中使用两个map方法,但Stream处理的时候并不会分别遍历两遍,其实写法等同于下面这种写到1个map中处理,改为下面这种写法,其实大家也就更容易明白为啥会没有达到我们预期的整体并行效果了:

1
2
3
4
5
6
7
java复制代码public PriceResult comparePriceInOnePlat1(List<String> products) {
return products.stream()
.map(product -> CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouBaoPrice(product)).thenCombine(CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouBaoDiscounts(product)), this::computeRealPrice).join())
.sorted(Comparator.comparingInt(PriceResult::getRealPrice))
.findFirst()
.get();
}

既然如此,这种场景是不是就不能使用Stream了呢?也不是,其实我们拆开成两个Stream分步操作下其实就可以了。

再看下面的第二种实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public PriceResult comparePriceInOnePlat2(List<String> products) {
// 先触发各自平台的并行处理
List<CompletableFuture<PriceResult>> completableFutures = products.stream()
.map(product -> CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouBaoPrice(product)).thenCombine(CompletableFuture.supplyAsync(() -> HttpRequestMock.getMouBaoDiscounts(product)), this::computeRealPrice))
.collect(Collectors.toList());
// 在独立的流中,等待所有并行处理结束,做最终结果处理
return completableFutures.stream()
.map(CompletableFuture::join)
.sorted(Comparator.comparingInt(PriceResult::getRealPrice))
.findFirst()
.get();
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
ini复制代码07:39:16.072[ForkJoinPool.commonPool-worker-6|17]获取某宝上 Iphone13红色的价格完成: 5199
07:39:16.072[ForkJoinPool.commonPool-worker-9|12]获取某宝上 Iphone13黑色的价格完成: 5199
07:39:16.072[ForkJoinPool.commonPool-worker-2|13]获取某宝上 Iphone13黑色的优惠完成: -200
07:39:16.072[ForkJoinPool.commonPool-worker-11|14]获取某宝上 Iphone13白色的价格完成: 5199
07:39:16.072[ForkJoinPool.commonPool-worker-4|15]获取某宝上 Iphone13白色的优惠完成: -200
07:39:16.072[ForkJoinPool.commonPool-worker-13|16]获取某宝上 Iphone13红色的优惠完成: -200
07:39:16.072[ForkJoinPool.commonPool-worker-2|13]某宝最终价格计算完成:4999
07:39:16.072[ForkJoinPool.commonPool-worker-4|15]某宝最终价格计算完成:4999
07:39:16.072[ForkJoinPool.commonPool-worker-13|16]某宝最终价格计算完成:4999
获取最优价格信息:【平台:某宝, 原价:5199, 折扣:0, 实付价:4999】
-----执行耗时: 1142ms ------

从执行结果可以看出,三个商品并行处理,整体处理耗时相比前面编码方式有很大提升,达到了预期的效果。

📢归纳下:

因为Stream的操作具有延迟执行的特点,且只有遇到终止操作(比如collect方法)的时候才会真正的执行。所以遇到这种需要并行处理且需要合并多个并行处理流程的情况下,需要将并行流程与合并逻辑放到两个Stream中,这样分别触发完成各自的处理逻辑,就可以了。

甜点:并发和并行的区别

对一个吃货而言,主餐完毕,总得来点餐后甜点才够满足。

在前面的内容中呢,我们始终是在围绕并行处理这个话题在展开。实际工作的时候,我们对于并发这个词肯定也不陌生,高并发这个词,就像高端人士酒杯中那八二年的拉菲一般,成了每一个开发人员简历上用来彰显实力的一个标签。

那么,并发和并行到底啥区别?这里我们也简单的概括下。

并发

关于并发的详细内容,可以参见我写的另一篇内容,也即本篇文章的姊妹篇《不堆概念,换个角度聊多线程并发编程》,下面这里简单的介绍下并发的概念。

所谓并发,其关注的点是服务器的吞吐量情况,也就是服务器可以在单位时间内同时处理多少个请求。并发是通过多线程的方式来实现的,充分利用当前CPU多核能力,同时使用多个进程去处理业务,使得同一个机器在相同时间内可以处理更多的请求,提升吞吐量。

image.png
所有的操作在一个线程中串行推进,如果有多个线程同步处理,则同时有多个请求可以被处理。但是因为是串行处理,所以如果某个环节需要对外交互时,比如等待网络IO的操作,会使得当前线程处于阻塞状态,直到资源可用时被唤醒继续往后执行。

image.png

对于高并发场景,服务器的线程资源是非常宝贵的。如果频繁的处于阻塞则会导致浪费,且线程频繁的阻塞、唤醒切换动作,也会加剧整体系统的性能损耗。所以并发这种多线程场景,更适合CPU密集型的操作。

并行

所谓并行,就是将同一个处理流程没有相互依赖的部分放到多个线程中进行同时并行处理,以此来达到相对于串行模式更短的单流程处理耗时的效果,进而提升系统的整体响应时长与吞吐量。

image.png

基于异步编程实现的并行操作也是借助线程池的方式,通过多线程同时执行来实现效率提升的。与并发的区别在于:并行通过将任务切分为一个个可独立处理的小任务块,然后基于系统调度策略,将需要执行的任务块分配给空闲可用工作线程去处理,如果出现需要等待的场景(比如IO请求)则工作线程会将此任务先放下,继续处理后续的任务,等之前的任务IO请求好了之后,系统重新分配可用的工作线程来处理。

image.png

根据上面的示意图介绍可以看出,异步并行编程,对于工作线程的利用率上升,不会出现工作线程阻塞的情况,但是因为任务拆分、工作线程间的切换调度等系统层面的开销也会随之加大。

如何选择

前面介绍了下并发与并行两种模式的特点、以及各自的优缺点。所以选择采用并发还是并行方式来提升系统的处理性能,还需要结合实际项目场景来确定。

综合而言

  1. 如果业务处理逻辑是CPU密集型的操作,优先使用基于线程池实现并发处理方案(可以避免线程间切换导致的系统性能浪费)。
  2. 如果业务处理逻辑中存在较多需要阻塞等待的耗时场景、且相互之间没有依赖,比如本地IO操作、网络IO请求等等,这种情况优先选择使用并行处理策略(可以避免宝贵的线程资源被阻塞等待)。

总结回顾

好啦,关于JAVA中CompletableFuture的使用,以及并行编程相关的内容呢就介绍到这里啦。看到这里,相信您应该有所收获吧?那么你的项目里有这种适合并行处理的场景吗?你在处理并行场景的时候是怎么做的呢?评论区一起讨论下吧~~

补充:

本文中有提及CompletableFuture执行时所使用的默认线程池是ForkJoinPool,早在JAVA7版本就已经被引入,但是很多人对ForkJoinPool不是很了解,实际项目中使用的也比较少。其实对ForkJoinPool的合理利用,可以让我们在面对某些多线程场景时会更加的从容高效。在后面的文章中,我会针对ForkJoinPool有关的内容进行专门的介绍与探讨,如果有兴趣,可以点个关注,及时获取后续的内容。

此外

  • 关于本文中涉及的演示代码的完整示例,我已经整理并提交到github中,如果您有需要,可以自取:github.com/veezean/Jav…

我是悟道,聊技术、又不仅仅聊技术~

如果觉得有用,请点赞 + 关注让我感受到您的支持。也可以关注下我的公众号【架构悟道】,获取更及时的更新。

期待与你一起探讨,一起成长为更好的自己。


我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

使用Threejs实现炫酷的赛博朋克风格3D数字地球大屏

发表于 2022-07-25

我正在参加「创意开发 投稿大赛」详情请看:掘金创意开发大赛来了!

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

背景

近期工作有涉及到数字大屏的需求,于是利用业余时间,结合 Three.js 和 CSS实现赛博朋克2077风格视觉效果 实现炫酷 3D 数字地球大屏页面。页面使用 React + Three.js + Echarts + stylus 技术栈,本文涉及到的主要知识点包括:THREE.Spherical 球体坐标系的应用、Shader 结合 TWEEN 实现飞线和冲击波动画效果、dat.GUI 调试工具库的使用、clip-path 创建不规则图形、Echarts 的基本使用方法、radial-gradient 创建雷达图形及动画、GlitchPass 添加故障风格后期、Raycaster 网格点击事件等。

效果

如下图 👇 所示,页面主要头部、两侧卡片、底部仪表盘以及主体 3D 地球 🌐 构成,地球外围有 飞线 动画和 冲击波 动画效果 🌠 ,通过 🖱 鼠标可以旋转和放大地球。点击第一张卡片的 START ⬜ 按钮会给页面添加故障风格后期 ⚡,双击地球会弹出随机提示语弹窗。

  • 💻 本页面仅适配 PC 端,大屏访问效果更佳。
  • 👁‍🗨 在线预览地址1:3d-eosin.vercel.app/#/earthDigi…
  • 👁‍🗨 在线预览地址2:dragonir.github.io/3d/#/earthD…

码上掘金

实现

📦 资源引入

引入开发必备的资源,其中除了基础的 React 和样式表之外,dat.gui 用于动态控制页面参数,其他剩余的主要分为两部分:Three.js相关, OrbitControls 用于镜头轨道控制、TWEEN 用于补间动画控制、mergeBufferGeometries 用户合并模型、EffectComposer RenderPass GlitchPass 用于生成后期故障效果动画、 lineFragmentShader 是飞线的 Shader、Echarts相关按需引入需要的组件,最后使用 echarts.use 使其生效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码import './index.styl';
import React from 'react';
import * as dat from 'dat.gui';
// three.js 相关
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { TWEEN } from 'three/examples/jsm/libs/tween.module.min.js';
import { mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { GlitchPass } from 'three/examples/jsm/postprocessing/GlitchPass.js';
import lineFragmentShader from '@/containers/EarthDigital/shaders/line/fragment.glsl';
// echarts 相关
import * as echarts from 'echarts/core';
import { BarChart /*...*/ } from 'echarts/charts';
import { GridComponent /*...*/ } from 'echarts/components';
import { LabelLayout /*...*/ } from 'echarts/features';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([BarChart, GridComponent, /* ...*/ ]);

📃 页面结构

页面主要结构如以下代码所示,.webgl 用于渲染 3D 数字地球;.header 是页面顶部,里面包括时间、日期、星际坐标、Cyberpunk 2077 Logo、本人 Github 仓库地址等;.aside 是左右两侧的图表展示区域;.footer 是底部的仪表盘,展示一些雷达动画和文本信息;如果仔细观察,可以看出背景有噪点效果,.bg 就是用于生成噪点背景效果。

1
2
3
4
5
6
7
8
9
js复制代码<div className='earth_digital'>
<canvas className='webgl'></canvas>
<header className='hud header'>
<header></header>
<aside className='hud aside left'></aside>
<aside className='hud aside right'></aside>
<footer className='hud footer'></footer>
<section className="bg"></section>
</div>

🔩 场景初始化

定义一些全局变量和参数,初始化场景、相机、镜头轨道控制器、页面缩放监听、添加页面重绘更新动画等进行场景初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
js复制代码const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector('canvas.webgl'),
antialias: true,
alpha: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// 创建场景
const scene = new THREE.Scene();
// 创建相机
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, .01, 50);
camera.position.set(0, 0, 15.5);
// 添加镜头轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enablePan = false;
// 页面缩放监听并重新更新场景和相机
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
}, false);
// 页面重绘动画
renderer.setAnimationLoop( _ => {
TWEEN.update();
earth.rotation.y += 0.001;
renderer.render(scene, camera);
});

🌐 创建点状地球

具体思路是使用 THREE.Spherical 创建一个球体坐标系 〽,然后创建 10000 个平面网格圆点,将它们的空间坐标转换成球坐标,并使用 mergeBufferGeometries 将它们合并为一个网格。然后使用一张如下图所示的地图图片作为材质,在 shader 中根据材质图片的颜色分布调整圆点的大小和透明度,根据传入的参数调整圆点的颜色和大小比例。然后创建一个球体 SphereGeometry,使用生成的着色器材质,并将它添加到场景中。到此,一个点状地球 🌐 模型就完成了,具体实现如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
js复制代码// 创建球类坐标
let sph = new THREE.Spherical();
let dummyObj = new THREE.Object3D();
let p = new THREE.Vector3();
let geoms = [], rad = 5, r = 0;
let dlong = Math.PI * (3 - Math.sqrt(5));
let dz = 2 / counter;
let long = 0;
let z = 1 - dz / 2;
let params = {
colors: { base: '#f9f002', gradInner: '#8ae66e', gradOuter: '#03c03c' },
reset: () => { controls.reset() }
}
let uniforms = {
impacts: { value: impacts },
// 陆地色块大小
maxSize: { value: .04 },
// 海洋色块大小
minSize: { value: .025 },
// 冲击波高度
waveHeight: { value: .1 },
// 冲击波范围
scaling: { value: 1 },
// 冲击波径向渐变内侧颜色
gradInner: { value: new THREE.Color(params.colors.gradInner) },
// 冲击波径向渐变外侧颜色
gradOuter: { value: new THREE.Color(params.colors.gradOuter) }
}
// 创建10000个平面圆点网格并将其定位到球坐标
for (let i = 0; i < 10000; i++) {
r = Math.sqrt(1 - z * z);
p.set( Math.cos(long) * r, z, -Math.sin(long) * r).multiplyScalar(rad);
z = z - dz;
long = long + dlong;
sph.setFromVector3(p);
dummyObj.lookAt(p);
dummyObj.updateMatrix();
let g = new THREE.PlaneGeometry(1, 1);
g.applyMatrix4(dummyObj.matrix);
g.translate(p.x, p.y, p.z);
let centers = [p.x, p.y, p.z, p.x, p.y, p.z, p.x, p.y, p.z, p.x, p.y, p.z];
let uv = new THREE.Vector2((sph.theta + Math.PI) / (Math.PI * 2), 1. - sph.phi / Math.PI);
let uvs = [uv.x, uv.y, uv.x, uv.y, uv.x, uv.y, uv.x, uv.y];
g.setAttribute('center', new THREE.Float32BufferAttribute(centers, 3));
g.setAttribute('baseUv', new THREE.Float32BufferAttribute(uvs, 2));
geoms.push(g);
}
// 将多个网格合并为一个网格
let g = mergeBufferGeometries(geoms);
let m = new THREE.MeshBasicMaterial({
color: new THREE.Color(params.colors.base),
onBeforeCompile: shader => {
shader.uniforms.impacts = uniforms.impacts;
shader.uniforms.maxSize = uniforms.maxSize;
shader.uniforms.minSize = uniforms.minSize;
shader.uniforms.waveHeight = uniforms.waveHeight;
shader.uniforms.scaling = uniforms.scaling;
shader.uniforms.gradInner = uniforms.gradInner;
shader.uniforms.gradOuter = uniforms.gradOuter;
// 将地球图片作为参数传递给shader
shader.uniforms.tex = { value: new THREE.TextureLoader().load(imgData) };
shader.vertexShader = vertexShader;
shader.fragmentShader = fragmentShader;
);
}
});
// 创建球体
const earth = new THREE.Mesh(g, m);
earth.rotation.y = Math.PI;
earth.add(new THREE.Mesh(new THREE.SphereGeometry(4.9995, 72, 36), new THREE.MeshBasicMaterial({ color: new THREE.Color(0x000000) })));
earth.position.set(0, -.4, 0);
scene.add(earth);

🔧 添加调试工具

为了实时调整球体的样式和后续飞线和冲击波的参数调整,可以使用工具库 dat.GUI。它可以创建一个表单添加到页面,通过调整表单上面的参数、滑块和数值等方式绑定页面参数,参数值更改后可以实时更新画面,这样就不用一边到编辑器调整代码一边到浏览器查看效果了。基本用法如下,本例中可以在页面通过点击键盘 ⌨ H键显示或隐藏参数表单,通过表单可以修改 🌐 地球背景色、飞线颜色、冲击波幅度大小等效果。

1
2
3
4
5
6
js复制代码const gui = new dat.GUI();
gui.add(uniforms.maxSize, 'value', 0.01, 0.06).step(0.001).name('陆地');
gui.add(uniforms.minSize, 'value', 0.01, 0.06).step(0.001).name('海洋');
gui.addColor(params.colors, 'base').name('基础色').onChange(val => {
earth && earth.material.color.set(val);
});

📌 如果想要了解更多关于 dat.GUI 的属性和方法,可以访问本文末尾提供的官方文档地址

💫 添加飞线和冲击波

这部分内容实现地球表层的飞线和冲击波效果 🌠,基本思路是:使用 THREE.Line 创建 10 条随机位置的飞线路径,通过 setPath 方法设置飞线的路径 然后通过 TWEEN 更新飞线和冲击波扩散动画,一条动画结束后,在终点的位置基础上重新调整飞线开始的位置,通过更新 Shader 参数 实现飞线和冲击波效果,并循环执行该过程,最后将飞线和冲击波关联到地球 🌐 上,具体实现如以下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
js复制代码let maxImpactAmount = 10, impacts = [];
let trails = [];
for (let i = 0; i < maxImpactAmount; i++) {
impacts.push({
impactPosition: new THREE.Vector3().random().subScalar(0.5).setLength(5),
impactMaxRadius: 5 * THREE.Math.randFloat(0.5, 0.75),
impactRatio: 0,
prevPosition: new THREE.Vector3().random().subScalar(0.5).setLength(5),
trailRatio: {value: 0},
trailLength: {value: 0}
});
makeTrail(i);
}
// 创建虚线材质和线网格并设置路径
function makeTrail(idx){
let pts = new Array(100 * 3).fill(0);
let g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
let m = new THREE.LineDashedMaterial({
color: params.colors.gradOuter,
transparent: true,
onBeforeCompile: shader => {
shader.uniforms.actionRatio = impacts[idx].trailRatio;
shader.uniforms.lineLength = impacts[idx].trailLength;
// 片段着色器
shader.fragmentShader = lineFragmentShader;
}
});
// 创建飞线
let l = new THREE.Line(g, m);
l.userData.idx = idx;
setPath(l, impacts[idx].prevPosition, impacts[idx].impactPosition, 1);
trails.push(l);
}
// 飞线网格、起点位置、终点位置、顶点高度
function setPath(l, startPoint, endPoint, peakHeight) {
let pos = l.geometry.attributes.position;
let division = pos.count - 1;
let peak = peakHeight || 1;
let radius = startPoint.length();
let angle = startPoint.angleTo(endPoint);
let arcLength = radius * angle;
let diameterMinor = arcLength / Math.PI;
let radiusMinor = (diameterMinor * 0.5) / cycle;
let peakRatio = peak / diameterMinor;
let radiusMajor = startPoint.length() + radiusMinor;
let basisMajor = new THREE.Vector3().copy(startPoint).setLength(radiusMajor);
let basisMinor = new THREE.Vector3().copy(startPoint).negate().setLength(radiusMinor);
let tri = new THREE.Triangle(startPoint, endPoint, new THREE.Vector3());
let nrm = new THREE.Vector3();
tri.getNormal(nrm);
let v3Major = new THREE.Vector3();
let v3Minor = new THREE.Vector3();
let v3Inter = new THREE.Vector3();
let vFinal = new THREE.Vector3();
for (let i = 0; i <= division; i++) {
let divisionRatio = i / division;
let angleValue = angle * divisionRatio;
v3Major.copy(basisMajor).applyAxisAngle(nrm, angleValue);
v3Minor.copy(basisMinor).applyAxisAngle(nrm, angleValue + Math.PI * 2 * divisionRatio * 1);
v3Inter.addVectors(v3Major, v3Minor);
let newLength = ((v3Inter.length() - radius) * peakRatio) + radius;
vFinal.copy(v3Inter).setLength(newLength);
pos.setXYZ(i, vFinal.x, vFinal.y, vFinal.z);
}
pos.needsUpdate = true;
l.computeLineDistances();
l.geometry.attributes.lineDistance.needsUpdate = true;
impacts[l.userData.idx].trailLength.value = l.geometry.attributes.lineDistance.array[99];
l.material.dashSize = 3;
}

添加动画过渡效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
js复制代码for (let i = 0; i < maxImpactAmount; i++) {
tweens.push({
runTween: () => {
let path = trails[i];
let speed = 3;
let len = path.geometry.attributes.lineDistance.array[99];
let dur = len / speed;
let tweenTrail = new TWEEN.Tween({ value: 0 })
.to({value: 1}, dur * 1000)
.onUpdate( val => {
impacts[i].trailRatio.value = val.value;
});
var tweenImpact = new TWEEN.Tween({ value: 0 })
.to({ value: 1 }, THREE.Math.randInt(2500, 5000))
.onUpdate(val => {
uniforms.impacts.value[i].impactRatio = val.value;
})
.onComplete(val => {
impacts[i].prevPosition.copy(impacts[i].impactPosition);
impacts[i].impactPosition.random().subScalar(0.5).setLength(5);
setPath(path, impacts[i].prevPosition, impacts[i].impactPosition, 1);
uniforms.impacts.value[i].impactMaxRadius = 5 * THREE.Math.randFloat(0.5, 0.75);
tweens[i].runTween();
});
tweenTrail.chain(tweenImpact);
tweenTrail.start();
}
});
}

📟 创建头部

头部机甲风格的形状是通过纯 CSS 实现的,利用 clip-path 属性,使用不同的裁剪方式创建元素的可显示区域,区域内的部分显示,区域外的隐藏。

1
2
3
stylus复制代码.header
background #f9f002
clip-path polygon(0 0, 100% 0, 100% calc(100% - 35px), 75% calc(100% - 35px), 72.5% 100%, 27.5% 100%, 25% calc(100% - 35px), 0 calc(100% - 35px), 0 0)

📌 如果想了解关于 clip-path 的更多知识,可以访问文章末尾提供的 MDN 地址。

📊 添加两侧卡片

两侧的 卡片 🎴,也是机甲风格形状,同样由 clip-path 生成的。卡片有实心、实心点状背景、镂空背景三种基本样式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
stylus复制代码.box
background-color #000
clip-path polygon(0px 25px, 26px 0px, calc(60% - 25px) 0px, 60% 25px, 100% 25px, 100% calc(100% - 10px), calc(100% - 15px) calc(100% - 10px), calc(80% - 10px) calc(100% - 10px), calc(80% - 15px) 100%, 80px calc(100% - 0px), 65px calc(100% - 15px), 0% calc(100% - 15px))
transition all .25s linear
&.inverse
border none
padding 40px 15px 30px
color #000
background-color var(--yellow-color)
border-right 2px solid var(--border-color)
&::before
content "T-71"
background-color #000
color var(--yellow-color)
&.dotted, &.dotted::after
background var(--yellow-color)
background-image radial-gradient(#00000021 1px, transparent 0)
background-size 5px 5px
background-position -13px -3px

卡片上的图表 📊,直接使用的是 Eachrts 插件,通过修改每个图表的配置来适配 赛博朋克 2077 的样式风格。

1
2
js复制代码const chart_1 = echarts.init(document.getElementsByClassName('chart_1')[0], 'dark');
chart_1 && chart_1.setOption(chart_1_option);

📌 Echarts 图标使用不是本文重点内容,想要了解更多细节内容,可访问其官网。

⏱ 添加底部仪表盘

底部仪表盘主要用于数据展示,并且添加了 3 个雷达扫描动画,雷达 📡 形状则是通过 radial-gradient 径向渐变来实现的,然后利用 ::before 和 ::after 伪元素实现扫描动画效果,具体 keyframes 实现可以查看样式源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
stylus复制代码.radar
background: radial-gradient(center, rgba(32, 255, 77, 0.3) 0%, rgba(32, 255, 77, 0) 75%), repeating-radial-gradient(rgba(32, 255, 77, 0) 5.8%, rgba(32, 255, 77, 0) 18%, #20ff4d 18.6%, rgba(32, 255, 77, 0) 18.9%), linear-gradient(90deg, rgba(32, 255, 77, 0) 49.5%, #20ff4d 50%, #20ff4d 50%, rgba(32, 255, 77, 0) 50.2%), linear-gradient(0deg, rgba(32, 255, 77, 0) 49.5%, #20ff4d 50%, #20ff4d 50%, rgba(32, 255, 77, 0) 50.2%)
.radar:before
content ''
display block
position absolute
width 100%
height 100%
border-radius: 50%
animation blips 1.4s 5s infinite linear
.radar:after
content ''
display block
background-image linear-gradient(44deg, rgba(0, 255, 51, 0) 50%, #00ff33 100%)
width 50%
height 50%
animation radar-beam 5s infinite linear
transform-origin: bottom right
border-radius 100% 0 0 0

🤳 添加交互

故障风格后期

点击第一个卡片上的按钮 START ⬜,星际之旅进入 Hard 模式 😱,页面将会产生如下图所示的故障动画效果。它是通过引入 Three.js 内置的后期通道 GlitchPass 实现的,添加以下代码后,记得要在页面重绘动画中更新 composer。

1
2
3
4
js复制代码const composer = new EffectComposer(renderer);
composer.addPass( new RenderPass(scene, camera));
const glitchPass = new GlitchPass();
composer.addPass(glitchPass);

地球点击事件

使用 Raycaster 给地球网格添加点击事件,在地球上 双击鼠标 🖱,会弹出一个提示框 💬,并会随机加载一些提示文案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
window.addEventListener('dblclick', event => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = - (event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(earth.children);
if (intersects.length > 0) {
this.setState({
showModal: true,
modelText: tips[Math.floor(Math.random() * tips.length)]
});
}
}, false);

🎥 添加入场动画等其他细节

最后,还添加了一些样式细节和动画效果,如头部和两侧卡片的入场动画、头部时间坐标文字闪烁动画、第一张卡片按钮故障风格动画、Cyberpunk 2077 Logo 的阴影效果等。由于文章篇幅有限,不在这里细讲,感兴趣的朋友可以自己查看源码学习。也可以查看阅读我的另一篇文章 仅用CSS几步实现赛博朋克2077风格视觉效果 > 传送门 🚪 查看更多细节内容。

总结

本文包含的新知识点主要包括:

  • THREE.Spherical 球体坐标系的应用
  • Shader 结合 TWEEN 实现飞线和冲击波动画效果
  • dat.GUI 调试工具库的使用
  • clip-path 创建不规则图形
  • Echarts 的基本使用方法
  • radial-gradient 创建雷达图形及动画
  • GlitchPass 添加故障风格后期
  • Raycaster 网格点击事件等

后续计划:

本页面虽然已经做了很多效果和优化,但是还有很多改进的空间,后续我计划更新的内容包括:

  • 🌏 地球坐标和实际地理坐标结合,可以根据经纬度定位到国家、省份等具体位置
  • 💻 缩放适配不同屏幕尺寸
  • 📊 图表以及仪表盘展示一些真实的数据并且可以实时更新
  • 🌠 头部和卡片添加一些炫酷的描边动画
  • 🌟 添加宇宙星空粒子背景(有时间的话,现在的噪点背景也不错)
  • 🐌 性能优化

想了解其他前端知识或其他未在本文中详细描述的 Web 3D 开发技术相关知识,可阅读我往期的文章。转载请注明原文地址和作者。如果觉得文章对你有帮助,不要忘了一键三连哦 👍。

附录

  • 我的3D专栏可以点击此链接访问 👈
  • [1]. 🦊 Three.js 实现3D开放世界小游戏:阿狸的多元宇宙
  • [2]. 🔥 Three.js 火焰效果实现艾尔登法环动态logo
  • [3]. 🐼 Three.js 实现2022冬奥主题3D趣味页面,含冰墩墩
  • ...
  • [1]. 📷 前端实现很哇塞的浏览器端扫码功能
  • [2]. 🌏 前端瓦片地图加载之塞尔达传说旷野之息
  • [3]. 😱 仅用CSS几步实现赛博朋克2077风格视觉效果
  • ...

参考

  • [1]. threejs.org
  • [2]. github.com/dataarts/da…
  • [3]. echarts.apache.org/zh/index.ht…
  • [4]. www.cnblogs.com/pangys/p/13…
  • [5]. developer.mozilla.org/zh-CN/docs/…
  • [6]. developer.mozilla.org/zh-CN/docs/…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…899091…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%