背景

渠道对接需要编写大量重复职位信息的表单,怎样才能减少编写重复表单代码呢?

业务场景核心诉求:

  1. 具备远程动态渲染能力。也就是支持根据配置动态渲染表单。
  2. 支持完善的动态逻辑表达能力。 在表单配置层面,能够完善的应对业务场景中出现的联动及动态校验场景。
  3. 拥有完善的自定义能力。因为需要使用内部 UI 组件实现。
  4. 有处理异步数据源的能力。比如一个下拉选项,可选项需要从后端异步拉取,这是非常常见的需求。但如何让一个静态的 JSON 表达异步请求,是一个难点。

常见方案分析

社区支持度数据截止是本文编辑时 (2023.07.05),最新社区数据需要自行前往 github 确认

Formily 是阿里开源的动态化表单的解决方案,优雅的解决了多种复杂场景的表单的数据、状态管理及夸表单通信问题,同时规避了全量全然的弊端,性能优越,生态完备

XRender 是一套基于 React.js 框架的,轻量、易用、易上手的中后台「表单 / 表格 / 图表」解决方案

Formik 是一个流行的 React 表单库,它提供了一个简单但功能强大的 API 来处理表单状态和验证。Formik 支持多种输入控件类型和验证规则,并且可以自定义表单布局和提交行为。

React Hook Form 是另一个流行的 React 表单库,它采用 Hook API 来处理表单状态和验证。React Hook Form 专注于性能和易用性,可以处理大量的表单数据,并提供了一系列方便的工具和组件来构建表单。

Formily

社区支持度

Github Stars: 9.8K

优点

  • 功能丰富:提供了非常丰富的功能,包括表单状态管理、表单验证、表单布局、表单联动等。而且 Formily 的功能非常灵活,可以满足各种不同的表单需求。
  • 性能优秀:性能非常出色,它采用了一些优化策略来提高表单渲染和处理的效率,例如虚拟滚动、异步渲染、缓存等。
  • 扩展性强:提供了非常灵活的扩展接口,开发者可以通过插件、中间件、渲染器等方式来扩展 Formily 的功能。
  • 生态完备:生态圈非常完备,包括了大量的插件、工具、组件等,可以满足各种不同的表单需求。

缺点

  • 依赖较多,整体体积较大,压缩前 130k,压缩后 80k,包含桥接组件库
  • 学习成本较高,文档不够完善

这里附上一个 Formily 官方的 竞品对比

XRender

社区支持度

Github Stars: 6.2K

优点 :

  • 高性能:XRender 使用 GPU 加速技术,能够快速渲染复杂的表单界面,提高表单的渲染效率和用户体验。
    可扩展性:XRender 提供了可扩展的组件和插件机制,支持自定义组件和表单校验规则,方便用户根据具体需求进行扩展和定制。
  • 灵活性:XRender 提供了丰富的表单控件和布局方式,支持多种数据格式和表单交互方式,具有较高的灵活性。提供的playground 工具可以方便用来边写边看表单效果。
  • 兼容性:XRender 兼容性良好,能够支持大多数主流浏览器和操作系统,同时也能够支持移动设备。

缺点

  • 不支持动态数据拉取
  • 依赖较多:XRender 依赖于许多其他的前端框架和库,如 React、Vue、Ant Design 等,使用时需要引入相应的依赖和资源。

Formik(React 官方推荐)

社区支持度

Github Stars: 32.6K

React 官方推荐
image

优点

  • 简单易用:提供了简洁易懂的 API,使得表单处理变得更加容易。
  • 具有可扩展性:提供了灵活的插件系统,可以通过插件来扩展其功能,例如使用 Yup 插件进行表单验证。
  • 与 React 无缝集成:完全基于 React,可以与 React 的生命周期方法和状态管理库(如 Redux)无缝集成,方便开发者进行开发。
  • 高性能:采用了优化的渲染策略,仅在需要时才更新表单的组件,从而提高了性能。

缺点:

  • issue 中 bug 较多
  • 表单样式需要自己处理:Formik 只提供了表单处理逻辑,不包含任何样式,需要自行添加样式,不过也正好适合自定义 ui 的情况

React Hook Form

社区支持

github stars: 35.7K

优点

  • 足够轻量,以及使用 ref 保存值的特点,使它在性能敏感的场景有足够的吸引力。
  • 对非受控组件的场景支持很好。
  • 为动态变化的表单项提供了 custom-hooks,简洁的 API 帮助开发者应对复杂的场景。
  • 强大的类型支持。
  • 友善的社区和快速的支持。
  • 支持多种表单校验方式。

缺点

和 Formik 类似,不包含任意样式,某种程度上也是优点,支持自定义 UI。

总结

Formik 和 React Hook Form 更多是处理表单的性能和校验问题,并不是原生就支持根据配置动态渲染表单。

Formily 和 XRender 二者都支持 根据 JSON Schema 动态渲染表单。
Formily 功能全面,但体积更大,接自定义 UI 组件成本高,学习成本也高;
XRender 本身附带的有一个开源的表单编辑器。已经集成了物料拖拽等基础交互,所以,整体的表单编辑器可以基于它进行二次开发。渲染器这部分, 可以利用它易用的自定义组件能力整体将内置组件封装一层异步逻辑去支持缺失的异步数据源等功能,扩展性会比较强。这个 x-render 实际应用集合里有一些比较成熟线上例子。

总体来看,如果不是自己造轮子的话,XRender 更适合当前业务场景的需求。

其他

方案调研阶段,只看了 Formily 相关的文档,发现学习成本较高,包体积大,使用自定义组件成本高,同时使用的 JSON Schema 配置很复杂,所以没有采用。

虽然自己造轮子解决了类似的表单场景,但通过调研开源方案发现了自己的轮子缺失的部分,比如我使用了自己定义的表单字段规则而不是标准的 JSON Schema 对象,优点是联动和校验相比标准 JSON Schema 都要简单一些,缺点则是无法借助 ajv 等工具对配置对象进行有效性检测,只能全部依赖于人工测试。后期如果表单功能拓展,需要往 XRender 或者 其他方案迁移会存在阻碍。

如果能重来,我应该会选 标准 JSON Schema 规则约定表单配置, 方便自动检测、后期维护和迁移。表单渲染器部分还是会自己实现,因为需要使用内部的 UI 组件,而且需要异步获取数据,这两项对 XRender 的二次开发成本和自己实现一个简单的表单渲染器实际可能差不多,相比较之下,内部实现的代码更好维护一些。

参考

React 前端表单解决方案
Redux-form vs formik vs react-hook-form

原文发布于 CSDN,本文系近期迁移,想看更多欢迎访问 我的 CSDN 主页

本来想做一个网页版的调音器,在网上搜了一下,发现已经有人实现过了,但是自己对这个原理还是很感兴趣,所以参照人家的博客源码,用 svelte 重新实现了,最终效果请看 演示

一般用的调音器都是表盘式的,工作原理就是设备听到声音,分析声音的频率,判断出音高之后在屏幕上显示出来,在这个过程中我们可能需要解决这些问题:

  1. 获取设备麦克风权限
  2. 获取声音频率
  3. 将声音频率换算成音高
  4. 将当前声音最接近的音名显示到表盘中间,并用指针显示当前声音与该音名对应的声音之间的偏差。偏低则向左指,偏低则向右指。

看下这个过程的代码实现。

获取麦克风权限

获取麦克风权限使用 navigator.mediaDevices.getUserMedia() 这个 api,
它接受一个MediaStreamConstraints (en-US) 对象,指定了请求的媒体类型和相对应的参数。返回对象是一个 Promise.

这里只需要处理音频,所以参数传入{audio:true} 就可以

1
2
3
4
5
6
navigator.mediaDevices
.getUserMedia({ audio: true })
.then(handleStream)
.catch(function (error) {
alert(error.name + ": " + error.message);
});

获取到媒体流之后,要从中分析得到声音频率。

这里有一个坑,我把代码部署到服务器之后,发现无法获取到麦克风权限,控制台报错
image

image
发现必须使用 HTTPS 加密通信才能获取getUserMedia(),这个问题需要注意下。

获取声音数据

用来处理音频的 API 是AudioContext

AudioContext 接口表示由链接在一起的音频模块构建的音频处理图,每个模块由一个 AudioNode 表示。音频上下文控制它包含的节点的创建和音频处理或解码的执行。在做任何其他操作之前,您需要创建一个 AudioContext 对象,因为所有事情都是在上下文中发生的。建议创建一个 AudioContext 对象并复用它,而不是每次初始化一个新的 AudioContext 对象,并且可以对多个不同的音频源和管道同时使用一个 AudioContext 对象。

分析音频流需要使用 AudioContext.createAnalyser()

AudioContext 的 createAnalyser()方法能创建一个 AnalyserNode,可以用来获取音频时间和频率数据,以及实现数据可视化。

用 js 处理音频流需要用到
AudioContext.createScriptProcessor()

AudioContext 接口的 createScriptProcessor() 方法创建一个 ScriptProcessorNode 用于通过 JavaScript 直接处理音频.

scriptProcessor是一个ScriptProcessorNode

ScriptProcessorNode 接口允许使用 JavaScript 生成、处理、分析音频. 它是一个 AudioNode, 连接着两个缓冲区音频处理模块, 其中一个缓冲区包含输入音频数据,另外一个包含处理后的输出音频数据. 实现了 AudioProcessingEvent (en-US) 接口的一个事件,每当输入缓冲区有新的数据时,事件将被发送到该对象,并且事件将在数据填充到输出缓冲区后结束.

监听它的audioprocess事件就可以获得输入音频数据。

音频通路需要连接最终的目标节点
AudioContext.destination

AudioContext 的 destination 属性返回一个 AudioDestinationNode 表示 context 中所有音频(节点)的最终目标节点,一般是音频渲染设备,比如扬声器。

将以上几个节点依次连接起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 获取AudioContext实例
const audioContext = new window.AudioContext();
// 创建 analyser用来获取音频频率数据
const analyser = audioContext.createAnalyser();
//
const bufferSize = 4096;
const scriptProcessor = audioContext.createScriptProcessor(bufferSize, 1, 1);

const handleStream = (stream) => {
// 创建MediaStreamAudioSourceNode
// 把AudioBufferSourceNode连接到analyser
audioContext.createMediaStreamSource(stream).connect(analyser);
// 将analyser连接到 scriptProcessor,用js处理
analyser.connect(scriptProcessor);
//将scriptProcessor与destination连接,这样才能形成到达扬声器的通路
scriptProcessor.connect(audioContext.destination);
scriptProcessor.addEventListener("audioprocess", function (event) {
const data = event.inputBuffer.getChannelData(0); // 获取到音频数据
});
};

计算声音频率

音高检测算法已经很成熟了,不乏论文和资料,诸如 java、c/c++ 都有现成的库可用,而 js 在这方面显然是有缺失的。因为我的目的是快速实现一个调音器,所以我是基于一个现有的 c 音频库 aubio 用 emscripten 编译成 js,以便在浏览器里运行。

这里将 c 语言的音频库转译成 js 涉及到Web Assembluy相关的知识。关于 Web Assembly,我也不是特别了解,以后有机会做一下实践。这里只需要了解下 Web Assembly 是做什么的就好。

简单来说,WebAssembly 是一种新的字节码格式,目前可以使用 C、C++、Rust、Go、Java、C#等编译器(未来还有更多)来创建 wasm 模块(见下图)。该模块以二进制的格式发送到浏览器,浏览器提供了专门的虚拟机环境来运行 wasm 的代码,与 JavaScript 虚拟机共享内存和线程等资源。

编译之后的 audio 库有两个文件,一个 audio.js 文件,一个 audio.wasm 文件。

通过这个库提供的方法,可以计算得到音频的频率

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let pitchDetector = null;
Aubio().then(function (aubio) {
// 确保Aubio模块加载完毕之后使用
pitchDetector = new aubio.Pitch('default', bufferSize, 1, audioContext.sampleRate);
...
});


const handleStream = (stream) => {
audioContext.createMediaStreamSource(stream).connect(analyser);
analyser.connect(scriptProcessor);
scriptProcessor.connect(audioContext.destination);
scriptProcessor.addEventListener('audioprocess', function (event) {
// 计算获取频率
const frequency = pitchDetector.do(event.inputBuffer.getChannelData(0));
});
};

计算音名

关于音名

传统音乐理论中使用前七个拉丁字母:A、B、C、D、E、F、G(按此顺序则音高循序而上)以及一些变化(升音和降音符号)来标示不同的音符。两个音符间若相差一倍的频率,则称两者之间相差一个八度。为了标示同名但不同高度的音符,科学音调记号法利用字母及一个用来表示所在八度的阿拉伯数字,明确指出音符的位置。比如说,现在的标准调音音高 440 赫兹名为 A4,往上高八度则为 A5,继续向上可无限延伸;至于 A4 往下,则为 A3、A2…等。

把音高频率转成对应的 MIDI 编号,对照即可找到音名,比如 82.41 Hz 计算得到 MIDI 编号 38,即数字法音名 E2。维基百科-音符里有详细的描述、公式及八度命名系统对照表格。
image
image

写成代码

1
2
3
4
5
6
7
8
9
10
11
12
13
const middleA = 440;
const semitone = 69;
const getNote = function (frequency) {
const note = 12 * (Math.log(frequency / middleA) / Math.log(2));
return Math.round(note) + semitone;
};

scriptProcessor.addEventListener("audioprocess", function (event) {
const frequency = pitchDetector.do(event.inputBuffer.getChannelData(0));
if (frequency) {
const note = getNote(frequency);
}
});

计算指针偏转

并不是每个声音的频率都恰好准确地等于音名的频率,大部分声音的频率会相对于音名有一定偏移量,音分是用来衡量音高偏移的单位。

在十二平均律中,将一个八度音程分为 12 个半音。每一个半音的音程(相当于相邻钢琴键间的音程)等于 100 音分。相差一个八度的两个音之间的音程总共是 1200 音分。

12 个音名分别是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const noteStrings = [
"C",
"C♯",
"D",
"D♯",
"E",
"F",
"F♯",
"G",
"G♯",
"A",
"A♯",
"B",
];

每两个音名之间的差距为 100 音分,用音分来计算声音相对最近的音名的偏移量,并用来计算指针旋转角度。

下面是计算音分的方法,其计算公式详细可以去看维基百科-音分

1
2
3
4
5
6
7
8
9
const getStandardFrequency = function (note) {
return middleA * Math.pow(2, (note - semitone) / 12);
};

const getCents = function (frequency, note) {
return Math.floor(
(1200 * Math.log(frequency / getStandardFrequency(note))) / Math.log(2)
);
};

完整的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const onNoteDetected = function (note) {
const { value, cents, frequency } = note;
curValue = value;
curDeg = (cents / 50) * 45; // 计算得到旋转角度
curFrq = frequency;
};

scriptProcessor.addEventListener("audioprocess", function (event) {
const frequency = pitchDetector.do(event.inputBuffer.getChannelData(0));
if (frequency && onNoteDetected) {
const note = getNote(frequency);
onNoteDetected({
name: noteStrings[note % 12],
value: note,
cents: getCents(frequency, note),
octave: parseInt(note / 12) - 1,
frequency: frequency,
});
}
});

表盘实现

通过上面的计算我们已经获得了当前频率、当前音名与指针需要偏转的角度,表盘的实现部分就比较简单了,直接贴代码看一下。

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
// 刻度盘与指针
<script>
export let deg = 0;
const arr = Array.from(new Array(11).keys());
</script>

<div class="meter">
<div class="meter-dot"></div>
<div class="meter-pointer" style={`transform:rotate(${deg}deg)`}></div>
{#each arr as i}
<div class:meter-scale={true} class:meter-scale-strong = {i % 5 === 0 } style={`transform:rotate(${i * 9 - 45}deg)`}>
</div>
{/each}
</div>


<style>
.meter {
position: fixed;
left: 0;
right: 0;
bottom: 50%;
width: 400px;
height: 33%;
margin: 0 auto 5vh auto;
}

.meter-pointer {
width: 2px;
height: 100%;
background: #2c3e50;
transform: rotate(45deg);
transform-origin: bottom;
transition: transform 0.5s;
position: absolute;
right: 50%;
}

.meter-dot {
width: 10px;
height: 10px;
background: #2c3e50;
border-radius: 50%;
position: absolute;
bottom: -5px;
right: 50%;
margin-right: -4px;
}

.meter-scale {
width: 1px;
height: 100%;
transform-origin: bottom;
transition: transform 0.2s;
box-sizing: border-box;
border-top: 10px solid;
position: absolute;
right: 50%;
}

.meter-scale-strong {
width: 2px;
border-top-width: 20px;
}
</style>
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
73
74
75
76
77
78
79
<script>
import Note from "./Note.svelte";
export let value; //当前检测到的MIDI编号
export let frequency; //当前频率
export let nodeList; // node列表的dom节点
const octaveList = [2, 3, 4, 5];
const noteStrings = [
"C",
"C♯",
"D",
"D♯",
"E",
"F",
"F♯",
"G",
"G♯",
"A",
"A♯",
"B",
];

const handleActive = (e) => {
const dom = e.detail;
nodeList.scrollLeft =
dom.offsetLeft - (nodeList.clientWidth - dom.clientWidth) / 2;
};
</script>

<style>
.notes {
margin: auto;
width: 400px;
position: fixed;
top: 50%;
left: 0;
right: 0;
text-align: center;
}
.notes-list {
overflow: auto;
overflow: -moz-scrollbars-none;
white-space: nowrap;
-ms-overflow-style: none;
-webkit-mask-image: -webkit-linear-gradient(
left,
rgba(255, 255, 255, 0),
#fff,
rgba(255, 255, 255, 0)
);
}

.frequency {
font-size: 32px;
}

.frequency span {
font-size: 50%;
margin-left: 0.25em;
}

.notes-list::-webkit-scrollbar {
display: none;
}

@media (max-width: 768px) {
.notes {
width: 100%;
}
}
</style>

<div class="notes">
<div class="notes-list" bind:this="{nodeList}">
{#each octaveList as octave} {#each noteStrings as note, index} <Note
note={note} octave={octave} isActive={value === 12*(octave+1)+index}
listDom={nodeList} on:onActive={handleActive}/> {/each} {/each}
</div>
<div class="frequency">{frequency.toFixed(2)}<span>Hz</span></div>
</div>

以上就是代码的核心部分。

原文发布于 CSDN,本文系近期迁移,想看更多欢迎访问 我的 CSDN 主页

前言

一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷。

对于一个数据请求来说,可以分为发起网络请求后端处理浏览器响应三个步骤。

浏览器缓存可以帮助我们在第一和第三步骤中优化性能:比如说直接使用缓存而不发起请求,或者发起了请求但后端存储的数据和前端一致,那么就没有必要再将数据回传回来,这样就减少了响应数据。

缓存位置

浏览器缓存共有四种,且有一定的优先级,当所有缓存都没有命中时才回去进行网络请求。

  1. Service Worker
  2. Memory Cache
  3. Disk Cache
  4. Push Cache

Service Worker

Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker 的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。Service Worker 的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。

  1. 注册 Service worker :在你的 index.html 加入以下内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 判断当前浏览器是否支持serviceWorker */
if ("serviceWorker" in navigator) {
/* 当页面加载完成就创建一个serviceWorker */
window.addEventListener("load", function () {
/* 创建并指定对应的执行内容 */
/* scope 参数是可选的,可以用来指定你想让 service worker 控制的内容的子目录。 在这个例子里,我们指定了 '/',表示 根网域下的所有内容。这也是默认值。 */
navigator.serviceWorker
.register("./serviceWorker.js", { scope: "./" })
.then(function (registration) {
console.log(
"ServiceWorker registration successful with scope: ",
registration.scope
);
})
.catch(function (err) {
console.log("ServiceWorker registration failed: ", err);
});
});
}

2.安装 worker:在我们指定的处理程序 serviceWorker.js 中书写对应的安装及拦截逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 监听安装事件,install 事件一般是被用来设置你的浏览器的离线缓存逻辑 */
this.addEventListener("install", function (event) {
/* 通过这个方法可以防止缓存未完成,就关闭serviceWorker */
event.waitUntil(
/* 创建一个名叫V1的缓存版本 */
caches.open("v1").then(function (cache) {
/* 指定要缓存的内容,地址为相对于跟域名的访问路径 */
return cache.addAll(["./index.html"]);
})
);
});

/* 注册fetch事件,拦截全站的请求 */
this.addEventListener("fetch", function (event) {
event.respondWith(
// magic goes here

/* 在缓存中匹配对应请求资源直接返回 */
caches.match(event.request)
);
});

当 Service Worker 没有命中缓存的时候,我们需要去调用 fetch 函数获取数据。也就是说,如果我们没有在 Service Worker 命中缓存的话,会根据缓存查找优先级去查找数据。但是不管我们是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示我们是从 Service Worker 中获取的内容。

Memory Cache

Memory Cache 也就是内存中的缓存

优点:读取速度快

缺点:一旦我们关闭 Tab 页面,内存中的缓存也就被释放了。
当我们访问过页面以后,再次刷新页面,可以发现很多数据都来自于内存缓存

当我们刷新一个已经打开的页面,通常可以看到许多 memory cache 的资源。
image

Disk Cache

Disk Cache 也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上。

在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。绝大部分的缓存都来自 Disk Cache,关于 HTTP 的协议头中的缓存字段,我们会在下文进行详细介绍。

浏览器会把哪些文件丢进内存中?哪些丢进硬盘中?
关于这点,网上说法不一,不过以下观点比较靠得住:

对于大文件来说,大概率是不存储在内存中的,反之优先
当前系统内存使用率高的话,文件优先存储进硬盘

Push Cache

Push Cache(推送缓存)是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在 Chrome 浏览器中只有 5 分钟左右,同时它也并非严格执行 HTTP 头中的缓存指令。

浏览器可以拒绝接受已经存在的资源推送
你可以给其他域名推送资源
如果以上四种缓存都没有命中的话,那么只能发起请求来获取资源了。

缓存过程分析

image
根据是否需要向服务器重新发起 HTTP 请求将缓存过程分为两个部分,分别是强缓存和协商缓存。

强缓存

不会向服务器发送请求,直接从缓存中读取资源。

在 chrome 控制台的 Network 选项中可以看到该请求返回 200 的状态码,并且 Size 显示from disk cachefrom memory cache

image

强缓存可以通过设置两种 HTTP Header 实现:Expires 和 Cache-Control。

  1. Expires
    缓存过期时间,用来指定资源到期的时间,是服务器端的具体的时间点。

  2. Cache-Control
    比如:Cache-Control:max-age=300 时,则代表在这个请求正确返回的 5 分钟内再次加载资源,就会命中强缓存。
    image

Expires 和 Cache-Control 的差别

其实这两者差别不大,区别就在于 Expires 是 http1.0 的产物,Cache-Control 是 http1.1 的产物,两者同时存在的话,Cache-Control 优先级高于 Expires;在某些不支持 HTTP1.1 的环境下,Expires 就会发挥用处。所以 Expires 其实是过时的产物,现阶段它的存在只是一种兼容性的写法。

强缓存判断是否缓存的依据来自于是否超出某个时间或者某个时间段,而不关心服务器端文件是否已经更新,这可能会导致加载文件不是服务器端最新的内容,那我们如何获知服务器端内容是否已经发生了更新呢?此时我们需要用到协商缓存策略。

协商缓存

协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程。
协商缓存可以通过设置两种 HTTP Header 实现:Last-Modified 和 ETag 。

Last-Modified 和 If-Modified-Since

Last-Modified 指的是这个资源在服务器上的最后修改时间,
浏览器下一次请求这个资源,浏览器检测到有 Last-Modified 这个 header,于是添加 If-Modified-Since 这个 header,值就是 Last-Modified 中的值;服务器再次收到这个资源请求,会根据 If-Modified-Since 中的值与服务器中这个资源的最后修改时间对比,如果没有变化,返回 304 和空的响应体,直接从缓存读取
image

如下面这个请求:
request headers 中有 if-modified-since,表明浏览器之前请求过这个资源
image
response headers 返回状态 304,表明协商缓存成功,last-modified 与请求头中的 if-modified-since 一致。
image

如果 If-Modified-Since 的时间小于服务器中这个资源的最后修改时间,说明文件有更新,协商缓存失效,返回 200 和请求结果
image

弊端:

如果本地打开缓存文件,即使没有对文件进行修改,但还是会造成 Last-Modified 被修改,服务端不能命中缓存导致发送相同的资源。

因为 Last-Modified 只能以秒计时,如果在不可感知的时间内修改完成文件,那么服务端会认为资源还是命中了,不会返回正确的资源。

既然根据文件修改时间来决定是否缓存尚有不足,能否可以直接根据文件内容是否修改来决定缓存策略?所以在 HTTP / 1.1 出现了 ETag 和 If-None-Match

ETag 和 If-None-Match

Etag 是服务器响应请求时,返回当前资源文件的一个唯一标识(由服务器生成),只要资源有变化,Etag 就会重新生成。
浏览器在下一次加载资源向服务器发送请求时,会将上一次返回的 Etag 值放到 request header 里的 If-None-Match 里,服务器只需要比较客户端传来的 If-None-Match 跟自己服务器上该资源的 ETag 是否一致,就能很好地判断资源相对客户端而言是否被修改过了。如果服务器发现 ETag 匹配不上,那么直接以常规 GET 200 回包形式将新的资源(当然也包括了新的 ETag)发给客户端;如果 ETag 是一致的,则直接返回 304 知会客户端直接使用本地缓存即可。

image

协商缓存的两者对比

精度:Etag 要优于 Last-Modified。
性能:Last-Modified 要优于 Etag。
优先级:服务器校验优先考虑 Etag

实际使用策略

对与频繁变动的资源:
使用 Cache-Control: no-cache,使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。

对于不常变化的资源:
通常在处理这类资源时,给它们的 Cache-Control 配置一个很大的 max-age=31536000 (一年),这样浏览器之后请求相同的 URL 会命中强制缓存。而为了解决更新的问题,就需要在文件名(或者路径)中添加 hash, 版本号等动态字符,之后更改动态字符,从而达到更改引用 URL 的目的,让之前的强制缓存失效 (其实并未立即失效,只是不再使用了而已)。

用户行为如何触发缓存

打开网页,地址栏输入地址: 查找 disk cache 中是否有匹配。如有则使用;如没有则发送网络请求。
普通刷新 (F5):因为 TAB 并没有关闭,因此 memory cache 是可用的,会被优先使用(如果匹配的话)。其次才是 disk cache。
强制刷新 (Ctrl + F5):浏览器不使用缓存,因此发送的请求头部均带有 Cache-control: no-cache(为了兼容,还带了 Pragma: no-cache),服务器直接返回 200 和最新内容。

参考

深入理解浏览器的缓存机制
浏览器缓存:memory cache、disk cache、强缓存协商缓存等概念
service worker 是什么?看这篇就够了

原文发布于 CSDN,本文系近期迁移,想看更多欢迎访问 我的 CSDN 主页

问题描述

前段时间在做需求的时候,遇到一个关于forEachawait的坑,记录一下。

在业务代码中存在一段很普通的同步循环逻辑

1
2
3
4
5
6
7
8
9
function handleValueSync(value) {
console.log(value);
}

const list = [1, 2, 3, 4, 5, 6, 7, 8];

list.forEach((value) => handleValueSync(value));

// 结果,1,2,3,4,5,6,7,8

某一天,需求突然产生变化,原本同步的 handleValueSync 方法变成了异步的,所以我在原先代码基础上进行了简单的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
function handleValueAsync(value) {
if (value % 2 === 0) {
Promise.resolve().then(() => {
console.log(`${value}`);
});
} else {
console.log(value);
}
}

let list = [1, 2, 3, 4, 5, 6, 7, 8];

list.forEach(async (value) => await handleValueAsync(value));

预期: 每次循环时,一定会 await handleValueAsync结束,然后开始下一次循环。

1
// 预期结果 1,2,3,4,5,6,7,8

实际输出结果却是
image
await 似乎并没有生效。
换成 map 和 reduce 试一下

1
2
3
list.map(async (value) => await handleValueAsync(value));

list.reduce(async ({}, value) => await handleValueAsync(value), {});

也都是同样的结果
image

怎样使得 await 生效

网上搜索了一番,发现forEach就是不能支持异步调用,解决这个方法就是将 forEach 方法换成普通 for 循环就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function handleValueAsync(value) {
if (value % 2 === 0) {
Promise.resolve().then(() => {
console.log(`${value}`);
});
} else {
console.log(value);
}
}

let list = [1, 2, 3, 4, 5, 6, 7, 8];

async function awaitLoop() {
for (let i = 0; i < list.length; i++) {
await handleValueAsync(list[i]);
}
}
awaitLoop();

结果
image

这下就对了。。

Why?

为什么 forEach 无法支持 await,而 for 循环可以呢?
搜了一下 forEach 的实现原理,原来 forEach 是 es6 的新语法。

forEach() 是在第五版本里被添加到 ECMA-262 标准的;这样它可能在标准的其他实现中不存在,你可以在你调用 forEach() 之前插入下面的代码,在本地不支持的情况下使用 forEach()。

看下 MDN 上的forEach 的 pollyfill 源码

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
// Production steps of ECMA-262, Edition 5, 15.4.4.18
// Reference: http://es5.github.io/#x15.4.4.18
if (!Array.prototype.forEach) {
Array.prototype.forEach = function (callback, thisArg) {
var T, k;

if (this == null) {
throw new TypeError(" this is null or not defined");
}

// 1. Let O be the result of calling toObject() passing the
// |this| value as the argument.
var O = Object(this);

// 2. Let lenValue be the result of calling the Get() internal
// method of O with the argument "length".
// 3. Let len be toUint32(lenValue).
var len = O.length >>> 0;

// 4. If isCallable(callback) is false, throw a TypeError exception.
// See: http://es5.github.com/#x9.11
if (typeof callback !== "function") {
throw new TypeError(callback + " is not a function");
}

// 5. If thisArg was supplied, let T be thisArg; else let
// T be undefined.
if (arguments.length > 1) {
T = thisArg;
}

// 6. Let k be 0
k = 0;

// 7. Repeat, while k < len
while (k < len) {
var kValue;

// a. Let Pk be ToString(k).
// This is implicit for LHS operands of the in operator
// b. Let kPresent be the result of calling the HasProperty
// internal method of O with argument Pk.
// This step can be combined with c
// c. If kPresent is true, then
if (k in O) {
// i. Let kValue be the result of calling the Get internal
// method of O with argument Pk.
kValue = O[k];

// ii. Call the Call internal method of callback with T as
// the this value and argument list containing kValue, k, and O.
callback.call(T, kValue, k, O);
}
// d. Increase k by 1.
k++;
}
// 8. return undefined
};
}

简化一下,其实 forEach就是通过while循环多次执行 callback,相当于在 for 循环中执行了异步函数,forEach可以看成是这样

1
2
3
4
5
6
7
8
9
10
11
for (let i = 0; i < list.length; i++) {
(async function (value) {
if (value % 2 === 0) {
Promise.resolve().then(() => {
console.log(`${value}`);
});
} else {
console.log(value);
}
})(list[i]);
}

所以await不起作用

参考链接

当 async/await 遇到 map 和 reduce
forEach 和 await/async 的问题
在 foreach 中使用 async/await 的问题

Quick Start

这篇博客主要内容

  1. 使用 Hexo(默认主题) 搭建 Github Pages 的过程并使用 Github Actions 实现自动部署
  2. 使用主题后自动部署出错及修复的过程

Hexo 基本使用和自动部署过程

hexo 的基本使用参考 官方文档
使用 GitHub Actions 部署至 GitHub Pages 参考 在 GitHub Pages 上部署 Hexo

按照上述两个官方文档,可以顺利将默认主题的 Hexo 部署到 GitHub pages.
说明下,这种方式只用了一个 username.github.io仓库,main 分支上是 hexo 的源代码,gh-pages 分支上是 hexo 生成的文件。
配置的 workflow 可以实现:当 main 分支上有 push 时,重新执行 build 命令,将 public 文件下的内容创建一次 commit 然后 push 到 gh-pages 分支。
image

Hexo 使用主题后自动部署失败踩坑记录

问题场景

主题安装

clean-blog 主题 为例子
在项目根目录下执行

1
git clone https://github.com/klugjo/hexo-theme-clean-blog.git themes/clean-blog

将主题仓库 clone 到 themes 文件夹下

设置主题

在根目录下的_config.yml 文件中设置

1
theme: ”clean-blog“; // 注意与themes文件夹下的主题文件夹同名

部署出错

可以在本地预览一下是没有问题的,但是当我把这一个主题改动 push 到 main 分支后,站点页面空白了,查看 gh-pages 分支发现文件数量明显少了很多,想了一下应该是主题作为子仓库在云端时没有拉到相应的 github 仓库的代码,导致无法找到主题文件。

解决思路

既然是没有主题文件,我尝试修改 workflow, 在 build 前拉取主题文件仓库

1
2
3
4
5
6
7
8
9
10
11
12
- name: Install Dependencies
run: npm install
# 安装主题文件
- name: Install Theme
run: git clone git@github.com:klugjo/hexo-theme-clean-blog.git themes/clean-blog
- name: Build
run: npm run build
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public

push 之后触发重新构建报错
image

错误原因

这是因为你的开发机器上的用户通常与特定的 GitHub 帐户关联,并且该帐户具有对私有仓库的访问权限,而 GitHub Actions 用户只能看到它所附属的仓库。

如今,与 GitHub 进行身份验证的最佳方式是使用部署密钥(Deploy Keys)。这是一组 SSH 密钥,可以为用户提供对特定仓库的只读访问权限(默认情况下)。

所以需要重新生成一组 SSH 密钥。

解决过程

  1. 生成新的 ssh-keys
    deployKeys

  2. 将公钥添加到 Deploy Keys
    deployKeys

  3. 将 私钥 添加到 username.github.io 仓库的 密钥中
    deployKeys

  4. 在 workflow 中添加授权

1
2
3
4
5
6
7
8
9
10
11
12
- name: Install Dependencies
run: npm install
# 授权
- name: Give GitHub Actions access to hexo theme repo
uses: webfactory/ssh-agent@v0.7.0
with:
ssh-private-key: ${{ secrets.SECRET_REPO_DEPLOY_KEY }}
# 安装主题
- name: Install Theme
run: git clone git@github.com:klugjo/hexo-theme-clean-blog.git themes/clean-blog
- name: Build
run: npm run build

修改完之后重试,即可看到 CI 成功,页面成功部署。

顺便说明,这种问题只有在使用 git pull方式安装主题时会出现,如果使用 npm install 安装主题,则不会出现上面的问题。支持npm install 这种方式安装的主题有限,我最后选择了 Next, 简单快捷。

参考资料

Hexo 官方文档
在 GitHub Pages 上部署 Hexo
GitHub Actions can’t access private repos? Here’s how to fix it

0%