Hc's Blog

just do it


  • 首页

  • 归档

node+js实现大文件分片上传基本原理及实践

发表于 2020-01-13

一:什么是分片上传?

分片上传是把一个大的文件分成若干块,一块一块的传输。这样做的好处可以减少重新上传的开销。比如:
如果我们上传的文件是一个很大的文件,那么上传的时间应该会比较久,再加上网络不稳定各种因素的影响,很容易导致传输中断,用户除了重新上传文件外没有其他的办法,但是我们可以使用分片上传来解决这个问题。通过分片上传技术,如果网络传输中断,我们重新选择文件只需要传剩余的分片。而不需要重传整个文件,大大减少了重传的开销。

如下图是一个大文件分成很多小片段:

但是我们要如何选择一个合适的分片呢?

因此我们要考虑如下几个事情:

  1. 分片越小,那么请求肯定越多,开销就越大。因此不能设置太小。
  2. 分片越大,灵活度就少了。
  3. 服务器端都会有个固定大小的接收Buffer。分片的大小最好是这个值的整数倍。

因此,综合考虑到推荐分片的大小是2M-5M. 具体分片的大小需要根据文件的大小来确定,如果文件太大,建议分片的大小是5M,如果文件相对较小,那么建议分片的大小是2M。
实现文件分片上传的步骤如下:

  1. 先对文件进行md5加密。使用md5加密的优点是:可以对文件进行唯一标识,同样可以为后台进行文件完整性校验进行比对。
  2. 拿到md5值以后,服务器端查询下该文件是否已经上传过,如果已经上传过的话,就不用重新再上传。
  3. 对大文件进行分片。比如一个100M的文件,我们一个分片是5M的话,那么这个文件可以分20次上传。
  4. 向后台请求接口,接口里的数据就是我们已经上传过的文件块。(注意:为什么要发这个请求?就是为了能续传,比如我们使用百度网盘对吧,网盘里面有续传功能,当一个文件传到一半的时候,突然想下班不想上传了,那么服务器就应该记住我之前上传过的文件块,当我打开电脑重新上传的时候,那么它应该跳过我之前已经上传的文件块。再上传后续的块)。
  5. 开始对未上传过的文件块进行上传。(这个是第二个请求,会把所有的分片合并,然后上传请求)。
  6. 上传成功后,服务器会进行文件合并。最后完成。

二:理解Blob对象中的slice方法对文件进行分割及其他知识点

在编写代码之前,我们需要了解一些基本的知识点,然后在了解基础知识点之上,我们再去实践我们的大文件分片上传这么的一个demo。

  1. 首先我们来看下我们的Blob对象,如下代码所示:
1
2
var b = new Blob();
console.log(b);

如下所示:

image
如上图我们可以看到,我们的Blob对象自身有 size 和 type两个属性,及它的原型上有 slice() 方法。我们可以通过该方法来切割我们的二进制的Blob对象。

  1. 学习 blob.slice 方法

blob.slice(startByte, endByte) 是Blob对象中的一个方法,File对象它是继承Blob对象的,因此File对象也有该slice方法的。

参数:
startByte: 表示文件起始读取的Byte字节数。
endByte: 表示结束读取的字节数。

返回值:var b = new Blob(startByte, endByte); 该方法的返回值仍然是一个Blob类型。

我们可以使用 blob.slice() 方法对二进制的Blob对象进行切割,但是该方法也是有浏览器兼容性的,因此我们可以封装一个方法:如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function blobSlice(blob, startByte, endByte) {
if (blob.slice) {
return blob.slice(startByte, endByte);
}
// 兼容firefox
if (blob.mozSlice) {
return blob.mozSlice(startByte, endByte);
}
// 兼容webkit
if (blob.webkitSlice) {
return blob.webkitSlice(startByte, endByte);
}
return null;
}
  1. 理解 async/await 的使用

在我很早之前,我已经对async/await 的使用和优势做了讲解,有兴趣了解该知识点的,可以看我之前这篇文章.
因此我们现在来看下如下demo列子:

1
2
3
4
5
6
7
8
const hashFile2 = function(file) {
return new Promise(function(resolve, reject) {
console.log(111);
})
};
window.onload = async() => {
const hash = await hashFile2();
}

如上代码,如果我们直接刷新页面,就可以在控制台中输出 111 这个的字符。为什么我现在要讲解这个呢,因为待会我们的demo会使用到该知识点,所以提前讲解下理解下该知识。

  1. 理解 FileReader.readAsArrayBuffer()方法

该方法会按字节读取文件内容,并转换为 ArrayBuffer 对象。readAsArrayBuffer方法读取文件后,会在内存中创建一个 ArrayBuffer对象(二进制缓冲区),会将二进制数据存放在其中。通过此方式,我们就可以直接在网络中传输二进制内容。
其语法结构:

1
FileReader.readAsArrayBuffer(Blob|File);

Blob|File 必须参数,参数是Blob或File对象。

如下代码演示:

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
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset=" utf-8">
<title>readAsArrayBuffer测试</title>
</head>
<body>
<input type="file" id="file"/>
<script>
window.onload = function () {
var input = document.getElementById("file");
input.onchange = function () {
var file = this.files[0];
if (file) {
//读取本地文件,以gbk编码方式输出
var reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = function () {
console.log(this.result);
console.log(new Blob([this.result]))
}
}
}
}
</script>
</body>
</html>

如果我们现在上传的是文本文件的话,就会打印如下信息,如下所示:
image

三.使用 spark-md5 生成 md5文件

了解spark-md5,请看npm官网

下面我们来理解下 上传文件如何来得到 md5 的值。上传文件简单的如下demo, 代码所示:

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>文件上传</title>
<script src="https://code.jquery.com/jquery-3.4.1.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.0/spark-md5.js"></script>
</head>
<body>
<h1>大文件上传测试</h1>
<div>
<h3>自定义上传文件</h3>
<input id="file" type="file" name="avatar"/>
<div>
<input id="submitBtn" type="button" value="提交">
</div>
</div>
<script type="text/javascript">
$(function() {
const submitBtn = $('#submitBtn');
submitBtn.on('click', async () => {
var fileDom = $('#file')[0];
// 获取到的files为一个File对象数组,如果允许多选的时候,文件为多个
const files = fileDom.files;
const file = files[0]; // 获取第一个文件,因为文件是一个数组
if (!file) {
alert('没有获取文件');
return;
}
var fileSize = file.size; // 文件大小
var chunkSize = 2 * 1024 * 1024; // 切片的大小
var chunks = Math.ceil(fileSize / chunkSize); // 获取切片的个数
var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
var spark = new SparkMD5.ArrayBuffer();
var reader = new FileReader();
var currentChunk = 0;

reader.onload = function(e) {
const result = e.target.result;
spark.append(result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
console.log(`第${currentChunk}分片解析完成,开始解析${currentChunk + 1}分片`);
} else {
const md5 = spark.end();
console.log('解析完成');
console.log(md5);
}
};
function loadNext() {
var start = currentChunk * chunkSize;
var end = start + chunkSize > file.size ? file.size : (start + chunkSize);
reader.readAsArrayBuffer(blobSlice.call(file, start, end));
};
loadNext();
});
});
</script>
</body>
</html>

如上代码,首先我在 input type = ‘file’ 这样的会选择一个文件,然后点击进行上传,先获取文件的大小,然后定义一个分片的大小默认为2兆,使用 var chunks = Math.ceil(fileSize / chunkSize); // 获取切片的个数 方法获取切片的个数。
如果 fileSize(文件大小) 小于 chunkSize(2兆)的话,使用向上取整,因此为1个分片。同理如果除以的结果 是 1.2 这样的,那么就是2个分片了,依次类推…. 然后使用 SparkMD5.ArrayBuffer 方法了,详情可以看官网(http://npm.taobao.org/package/spark-md5). 先初始化当前的 currentChunk 分片为0,然后 reader.onload = function(e) {} 方法,如果当前的分片数量小于 chunks 的数量的话,会继续调用 loadNext()方法,该方法会读取下一个分片,开始的位置计算方式是:var start = currentChunk chunkSize;
currentChunk 的含义是第二个分片(从0开始的,因此这里它的值为1),结束的位置 计算方式为:
var end = start + chunkSize > file.size ? file.size : (start + chunkSize);
也就说,如果一个文件的大小是2.1兆的话,一个分片是2兆的话,那么它就最大分片的数量就是2片了,但是 currentChunk 默认从0开始的,因此第二个分片,该值就变成1了,因此 start的位置就是 var start = 1
2(兆)了,然后 var end = start + chunkSize > file.size ? file.size : (start + chunkSize);
如果 start + chunkSize 大于 文件的大小(file.size) 的话,那么就直接去 file.size(文件的大小),否则的话,结束位置就是 start + chunkSize 了。最后我们使用
blobSlice 进行切割,就切割到第二个分片的大小了,blobSlice.call(file, start, end),这样的方法。然后把切割的文件读取到内存中去,使用 reader.readAsArrayBuffer() 将buffer读取到内存中去了。继续会调用 onload 该方法,直到 进入else 语句内,那么 const md5 = spark.end(); 就生成了一个md5文件了。如上代码,如果我现在上传一个大文件的话,在控制台中就会打印如下信息了:如下图所示:
image

四. 使用koa+js实现大文件分片上传实践

注:根据网上demo来讲解的

先看下整个项目的架构如下:

1
2
3
4
5
6
7
8
9
10
11
|---- 项目根目录
| |--- app.js # node 入口文件
| |--- package.json
| |--- node_modules # 所有依赖的包
| |--- static # 存放静态资源文件目录
| | |--- js
| | | |--- index.js # 文件上传的js
| | |--- index.html
| |--- uploads # 保存上传文件后的目录
| |--- utils # 保存公用的js函数
| | |--- dir.js

static/index.html 文件代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>文件上传</title>
<script src="https://cdn.bootcss.com/axios/0.18.0/axios.min.js"></script>
<script src="https://code.jquery.com/jquery-3.4.1.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.0/spark-md5.js"></script>
</head>
<body>
<h1>大文件上传测试</h1>
<div>
<h3>自定义上传文件</h3>
<input id="file" type="file" name="avatar"/>
<div>
<input id="submitBtn" type="button" value="提交">
</div>
</div>
<script type="text/javascript" src="./js/index.js"></script>
</body>
</html>

运行页面,效果如下所示:
image
static/js/index.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
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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
$(document).ready(() => {
const chunkSize = 2 * 1024 * 1024; // 每个chunk的大小,设置为2兆
// 使用Blob.slice方法来对文件进行分割。
// 同时该方法在不同的浏览器使用方式不同。
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
const hashFile = (file) => {
return new Promise((resolve, reject) => {
const chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
function loadNext() {
const start = currentChunk * chunkSize;
const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
fileReader.onload = e => {
spark.append(e.target.result); // Append array buffer
currentChunk += 1;
if (currentChunk < chunks) {
loadNext();
} else {
console.log('finished loading');
const result = spark.end();
// 如果单纯的使用result 作为hash值的时候, 如果文件内容相同,而名称不同的时候
// 想保留两个文件无法保留。所以把文件名称加上。
const sparkMd5 = new SparkMD5();
sparkMd5.append(result);
sparkMd5.append(file.name);
const hexHash = sparkMd5.end();
resolve(hexHash);
}
};
fileReader.onerror = () => {
console.warn('文件读取失败!');
};
loadNext();
}).catch(err => {
console.log(err);
});
}
const submitBtn = $('#submitBtn');
submitBtn.on('click', async () => {
const fileDom = $('#file')[0];
// 获取到的files为一个File对象数组,如果允许多选的时候,文件为多个
const files = fileDom.files;
const file = files[0];
if (!file) {
alert('没有获取文件');
return;
}
const blockCount = Math.ceil(file.size / chunkSize); // 分片总数
const axiosPromiseArray = []; // axiosPromise数组
const hash = await hashFile(file); //文件 hash
// 获取文件hash之后,如果需要做断点续传,可以根据hash值去后台进行校验。
// 看看是否已经上传过该文件,并且是否已经传送完成以及已经上传的切片。
console.log(hash);

for (let i = 0; i < blockCount; i++) {
const start = i * chunkSize;
const end = Math.min(file.size, start + chunkSize);
// 构建表单
const form = new FormData();
form.append('file', blobSlice.call(file, start, end));
form.append('name', file.name);
form.append('total', blockCount);
form.append('index', i);
form.append('size', file.size);
form.append('hash', hash);
// ajax提交 分片,此时 content-type 为 multipart/form-data
const axiosOptions = {
onUploadProgress: e => {
// 处理上传的进度
console.log(blockCount, i, e, file);
},
};
// 加入到 Promise 数组中
axiosPromiseArray.push(axios.post('/file/upload', form, axiosOptions));
}
// 所有分片上传后,请求合并分片文件
await axios.all(axiosPromiseArray).then(() => {
// 合并chunks
const data = {
size: file.size,
name: file.name,
total: blockCount,
hash
};
axios.post('/file/merge_chunks', data).then(res => {
console.log('上传成功');
console.log(res.data, file);
alert('上传成功');
}).catch(err => {
console.log(err);
});
});
});
})

如上代码,和我们上面生成md5代码很类似,从添加到formData下面的代码不一样了,我们可以来简单的分析下,看下代码的具体含义:

1
const blockCount = Math.ceil(file.size / chunkSize); // 分片总数

上面的代码的含义是获取分片的总数,我们之前讲解过,然后使用 for循环遍历分片,依次把对应的分片添加到 formData数据里面去,如下所示代码:

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
const axiosPromiseArray = [];
const blockCount = Math.ceil(file.size / chunkSize); // 分片总数

for (let i = 0; i < blockCount; i++) {
const start = i * chunkSize;
const end = Math.min(file.size, start + chunkSize);
// 构建表单
const form = new FormData();
form.append('file', blobSlice.call(file, start, end));
form.append('name', file.name);
form.append('total', blockCount);
form.append('index', i);
form.append('size', file.size);
form.append('hash', hash);
// ajax提交 分片,此时 content-type 为 multipart/form-data
const axiosOptions = {
onUploadProgress: e => {
// 处理上传的进度
console.log(blockCount, i, e, file);
},
};
// 加入到 Promise 数组中
axiosPromiseArray.push(axios.post('/file/upload', form, axiosOptions));
}
// 所有分片上传后,请求合并分片文件
await axios.all(axiosPromiseArray).then(() => {
// 合并chunks
const data = {
size: file.size,
name: file.name,
total: blockCount,
hash
};
axios.post('/file/merge_chunks', data).then(res => {
console.log('上传成功');
console.log(res.data, file);
alert('上传成功');
}).catch(err => {
console.log(err);
});
});

如上代码,循环分片的总数,然后依次实列化formData数据,依次放入到formData实列中,然后分别使用 ‘/file/upload’ 请求数据,最后把所有请求成功的数据放入到 axiosPromiseArray 数组中,当所有的分片上传完成后,我们会使用 await axios.all(axiosPromiseArray).then(() => {}) 方法,最后我们会使用 ‘/file/merge_chunks’ 方法来合并文件。

下面我们来看看 app.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
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
const Koa = require('koa');
const app = new Koa();
const Router = require('koa-router');
const multer = require('koa-multer');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs-extra');
const koaBody = require('koa-body');
const { mkdirsSync } = require('./utils/dir');
const uploadPath = path.join(__dirname, 'uploads');
const uploadTempPath = path.join(uploadPath, 'temp');
const upload = multer({ dest: uploadTempPath });
const router = new Router();
app.use(koaBody());
/**
* single(fieldname)
* Accept a single file with the name fieldname. The single file will be stored in req.file.
*/
router.post('/file/upload', upload.single('file'), async (ctx, next) => {
console.log('file upload...')
// 根据文件hash创建文件夹,把默认上传的文件移动当前hash文件夹下。方便后续文件合并。
const {
name,
total,
index,
size,
hash
} = ctx.req.body;

const chunksPath = path.join(uploadPath, hash, '/');
if(!fs.existsSync(chunksPath)) mkdirsSync(chunksPath);
fs.renameSync(ctx.req.file.path, chunksPath + hash + '-' + index);
ctx.status = 200;
ctx.res.end('Success');
})

router.post('/file/merge_chunks', async (ctx, next) => {
const {
size,
name,
total,
hash
} = ctx.request.body;
// 根据hash值,获取分片文件。
// 创建存储文件
// 合并
const chunksPath = path.join(uploadPath, hash, '/');
const filePath = path.join(uploadPath, name);
// 读取所有的chunks 文件名存放在数组中
const chunks = fs.readdirSync(chunksPath);
// 创建存储文件
fs.writeFileSync(filePath, '');
if(chunks.length !== total || chunks.length === 0) {
ctx.status = 200;
ctx.res.end('切片文件数量不符合');
return;
}
for (let i = 0; i < total; i++) {
// 追加写入到文件中
fs.appendFileSync(filePath, fs.readFileSync(chunksPath + hash + '-' +i));
// 删除本次使用的chunk
fs.unlinkSync(chunksPath + hash + '-' +i);
}
fs.rmdirSync(chunksPath);
// 文件合并成功,可以把文件信息进行入库。
ctx.status = 200;
ctx.res.end('合并成功');
})
app.use(router.routes());
app.use(router.allowedMethods());
app.use(serve(__dirname + '/static'));
app.listen(9000, () => {
console.log('服务9000端口已经启动了');
});

如上代码:分别引入 koa, koa-router, koa-multer, koa-static, path, fs-extra, koa-body 依赖包。

koa-multer 的作用是为了处理上传文件的插件。

utils/dir.js 代码如下(该代码的作用是判断是否有这个目录,有这个目录的话,直接返回true,否则的话,创建该目录):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const path = require('path');
const fs = require('fs-extra');
const mkdirsSync = (dirname) => {
if(fs.existsSync(dirname)) {
return true;
} else {
if (mkdirsSync(path.dirname(dirname))) {
fs.mkdirSync(dirname);
return true;
}
}
}
module.exports = {
mkdirsSync
};
  1. /file/upload 请求代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    router.post('/file/upload', upload.single('file'), async (ctx, next) => {
    console.log('file upload...')
    // 根据文件hash创建文件夹,把默认上传的文件移动当前hash文件夹下。方便后续文件合并。
    const {
    name,
    total,
    index,
    size,
    hash
    } = ctx.req.body;

    const chunksPath = path.join(uploadPath, hash, '/');
    if(!fs.existsSync(chunksPath)) mkdirsSync(chunksPath);
    fs.renameSync(ctx.req.file.path, chunksPath + hash + '-' + index);
    ctx.status = 200;
    ctx.res.end('Success');
    })

如上代码,会处理 ‘/file/upload’ 这个请求,upload.single(‘file’), 的含义是:接受一个文件名称字段名。
单一文件将存储在req.file中,这是 koa-multer 插件的用法,具体可以看 koa-multer官网(https://www.npmjs.com/package/koa-multer)。 获取到文件后,请求成功回调,然后会在项目中的根目录下创建一个 uploads 这个目录,如下代码可以看到:

1
2
3
const uploadPath = path.join(__dirname, 'uploads');
const chunksPath = path.join(uploadPath, hash, '/');
if(!fs.existsSync(chunksPath)) mkdirsSync(chunksPath);

最后上传完成后,我们可以在我们的项目中可以看到我们所有的文件都在我们本地了,如下所示:
image
我们也可以在我们的网络中看到如下很多 ‘/file/upload’ 的请求,如下可以看到很多请求,说明我们的请求是分片上传的,如下所示:
image

  1. ‘/file/merge_chunks’

最后所有的分片请求上传成功后,我们会调用 ‘/file/merge_chunks’ 这个请求来合并所有的文件,根据我们的hash值,来获取文件分片。

如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 根据hash值,获取分片文件。
// 创建存储文件
// 合并
const chunksPath = path.join(uploadPath, hash, '/');
const filePath = path.join(uploadPath, name);
// 读取所有的chunks 文件名存放在数组中
const chunks = fs.readdirSync(chunksPath);
// 创建存储文件
fs.writeFileSync(filePath, '');
if(chunks.length !== total || chunks.length === 0) {
ctx.status = 200;
ctx.res.end('切片文件数量不符合');
return;
}
for (let i = 0; i < total; i++) {
// 追加写入到文件中
fs.appendFileSync(filePath, fs.readFileSync(chunksPath + hash + '-' +i));
// 删除本次使用的chunk
fs.unlinkSync(chunksPath + hash + '-' +i);
}
fs.rmdirSync(chunksPath);
// 文件合并成功,可以把文件信息进行入库。
ctx.status = 200;
ctx.res.end('合并成功');

如上代码,会循环分片的总数,然后把所有的分片写入到我们的filePath目录中,如这句代码:

1
fs.appendFileSync(filePath, fs.readFileSync(chunksPath + hash + '-' +i));

其中 filePath 的获取 是这句代码:const filePath = path.join(uploadPath, name); 也就是说在我们项目的根目录下的uploads文件夹下,这么做的原因是为了防止网络突然断开或服务器突然异常的情况下,文件上传到一半的时候,我们本地会保存一部分已经上传的文件,如果我们继续上传的时候,我们会跳过哪些已经上传后的文件,继续上传未上传的文件。这是为了断点续传做好准备的,下次我会分析下如何实现断点续传的原理了。如果我们把上面这两句代码注释掉,如下所示:

1
2
3
// 删除本次使用的chunk    
fs.unlinkSync(chunksPath + hash + '-' +i);
fs.rmdirSync(chunksPath);

我们就可以看到我们项目本地会有 uploads 会有很多分片文件了,如下所示:
image
当我们这个文件上传完成后,如上代码,我们会把它删除掉,因此如果我们不把该代码注释掉的话,是看不到效果的。

如果我们继续上传另外一个文件后,会在我们项目的根目录下生成第二个文件,如下所示:
image

小程序自定义导航栏适配,iPX底部适配

发表于 2019-08-15

本文主要介绍小程序自定义导航栏 title 和 logo 居中适配,iphoneX 底部适配

自定义导航栏

要用自定义的导航栏需要在 app.json 中配置

1
"navigationStyle": "custom"

设置手机状态栏的高度到store

1
2
3
4
setTopBarHeight () {
const res = wx.getSystemInfoSync()
store.commit('setStatusBarHeight', res.statusBarHeight)
}

动态改变自定义状态栏的paddingTop

1
2
3
<div class="header" :style="{paddingTop: statusBarHeight+'px' }" >
xxx
</div>
1
2
3
4
5
6
7
8
9
.header
top 0
height 44px
display flex
align-items center
padding-left 22px
position fixed
z-index 999
width calc(100% - 22px)

iphoneX底部适配

首先设置是否是iphoneX的布尔值到store

1
2
3
4
5
6
setIsIPhoneX () {
const res = wx.getSystemInfoSync()
if (res.model.search('iPhone X') !== -1) {
store.commit('setIsIPX', true)
}
}

在 iPhone X 中我们需要把吸底按钮往上偏移 34 像素,可通过在 CSS 样式中设置 padding-bottom 为 34px 实现,根据store里面的isIPX动态添加样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.feed-bottom-view {
width: 100%;
height: 48px; /* 吸底按钮的高度 */
bottom: 0;
opacity: 0.95;
position: fixed;
border-top-style: solid;
border-top-width: 0.5px; /* 分割线的高度 */
border-color: lightgrey;
background-color: #F8F8F8;
}
.feed-bottom-view-IPX {
/* iPhone X 内容往上偏移 34px */
padding-bottom: 34px;
}

网址性能优化

发表于 2019-06-12

规则1-减少HTTP请求

  • 雪碧图
  • 内联图片
  • 合并脚本和样式表
  • UI库按需加载,异步组件

规则2-使用内容分发网络(CDN)

规则3-合理使用浏览器缓存

css、js、img等静态资源使用强缓存(利用webpack等打包工具添加hash值,区分版本)

Expires头,Cache-Control:max-age, 可以同时使用,兼容IE,expires是绝对时间存在缺点,Cache-Control: max-age是相对时间。

html文档使用协商缓存

  • last-Modify/if-Modify-Since(最后修改时间),缺点:短时间,毫秒级内发生变化不会改变last-Modify
  • ETag/If-None-Match , (返回文件的唯一hash值,资源每次改变都会发生变化)

规则4-压缩组件

  • nginx服务器开启gzip压缩

规则5-将样式表放在顶部,脚本放底部

减少白屏时间,因为js脚本加载会阻塞页面渲染。

规则6-减少DNS查找

规则7-删除重复脚本,压缩js,css

利用tree-shaking 等技术删掉无用脚本

编写一个简易的Cli工具

发表于 2019-03-14

前置准备

  1. 开发环境

    Node 环境 (必须)
    VS Code 开发工具 (非必须,你可以使用你喜欢的开发工具)

  2. 注册 NPM 账号

    访问 NPM 官网 ,点击 Sign up 字样,输入邮箱用户名密码就可以完成创建了,注意这里的 Username 和 Password 将用于后续登录和发布包。Public Email 用于接收推送邮件。

开发

  1. 创建 NPM 项目

    新建一个空文件夹
    运行命令行,切换到你想存放文件的目录,执行新建文件。如:

    1
    2
    3
    4
    C:> cd D:/
    D:> mkdir gitchat-oni
    D:> cd gitchat-oni
    D:\gitchat-oni>

    新建一个 NPM 项目

    在刚刚的目录下执行 npm init ,输入项目的一下信息

  2. 配置程序入口

    打开 package.json 做一点点的修改:

    1
    2
    3
    4
    "main": "index.js",
    "bin": {
    "new": "./bin/new.js"
    }

    如果是插件的话,入口文件就是 main 所定义的;如果是工具的话,入口文件就是 bin 所定义的。

  3. 创建入口文件

    在项目中新建一个bin目录,在bin目录新建一个new.js的文件

  4. 编写入口文件

    安装工具所需要的依赖:

    1
    2
    3
    4
    5
    6
    D:\new>npm i commander --save
    npm notice created a lockfile as package-lock.json. You should commit this file.
    npm WARN gitchat-oni@1.0.0 No repository field.

    + commander@2.15.1
    added 1 package in 2.184s

    commander 是 Node.js 命令行接口的完整解决方案,打开 new.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #!/usr/bin/env node
    //这里需要声明文件运行环境,特别重要!!
    const program = require('commander');
    program
    .version(require('../package').version)
    .on('--help', printHelp)
    .usage('<command> [options]')
    .parse(process.argv)
    //process模块用来与当前进程互动,可以通过全局变量process访问,
    //不必使用require命令加载。它是一个EventEmitter对象的实例。

    function printHelp (){
    console.log("gitchat help");
    }

    这里其实我们的工具已经编写完毕了,它的功能就是打印出它自己的版本号,还有执行 –help 的时候,打印一段文字。

  5. 将工具上传到 NPM 上,或者使用 npm link 命令安装到本地

    首先要先登录到 NPM,使用刚刚注册的账号密码,这里的邮箱是用于接收推送邮件的。

    1
    2
    3
    4
    5
    D:\new>npm login
    Username: hc
    Password:
    Email: (this IS public) xxxx@qq.com
    Logged in as xiaohuoni on https://registry.npmjs.org/.

    然后执行推送 npm publish:

    上传成功,同时你可以查看邮箱,有一封来自 NPM 官方的邮件。

  6. 将自己的工具安装到电脑上

    1
    npm i new -g
  7. 定义 new-cli

    其实原理就是当你执行 new 之后,将一份脚手架代码,复制到你当前的目录下面。
    先定义命令。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    program
    .command('new')
    .description('新建一个项目')
    .action(function(name,other){
    console.log("执行了new")
    console.log("新建一个项目")
    console.log("执行拷贝")
    });
    program.parse(process.argv);
  8. 完善 new-cli 功能

    因为我们的 new 功能是执行拷贝,所以这里先安装几个插件:

    npm i path vinyl-fs through2 –save

    修改 new.js

    1
    2
    3
    .action(function(name,other){
    require('../lib/new.js').run(name,other)
    });

    新建 new.js 文件 (注意上面的引用的是 lib 目录下,我们创建的是 src 目录下,后面会转译):

    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
    import { join } from "path";
    import vfs from "vinyl-fs";
    import through from "through2";

    exports.run = function(name, other) {
    const cwd = join(__dirname, "../template");//从template目录下拷贝
    const dest = process.cwd();//当前执行命令的目录
    console.log(`新建一个项目在: ${dest}.`);
    vfs
    .src(["**/*", "!node_modules/**/*"], { cwd: cwd, cwdbase: true, dot: true })
    .pipe(template(dest, cwd))
    .pipe(vfs.dest(dest))
    .on("end", function() {
    console.log("新建项目完成");
    })
    .resume();
    /**
    * 执行拷贝
    */
    function template(dest, cwd) {
    return through.obj(function(file, enc, cb) {
    if (!file.stat.isFile()) {
    return cb();
    }
    console.log(file.path.replace(cwd + "/", ""));
    this.push(file);
    cb();
    });
    }
    };
  9. 使用 babel 转译代码

    开发中我们可能偏向于使用 es2016、es2017,或者 react、vue 等语法。比如我们的 new.js 中使用了 import,所以我们在项目中添加 babel 来转译代码,使得所有的语法都是 es2015 的修改 package.json(在最后面增加)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
      "devDependencies": {
    "babel-cli": "^6.10.1",
    "babel-plugin-add-module-exports": "^0.2.1",
    "babel-plugin-transform-runtime": "^6.9.0",
    "babel-preset-es2015": "^6.9.0",
    "babel-preset-stage-0": "~6.5.0",
    "babel-runtime": "^6.9.2"
    },
    "babel": {
    "presets": ["es2015", "stage-0"],
    "plugins": ["add-module-exports", "transform-runtime"]
    }

    修改package.json

    1
    2
    3
    4
      "scripts": {
    "test":"xxxxxx"
    "build": "rm -rf lib && babel src --out-dir lib"
    },
  10. 添加模板文件

新建 D:\newtemplate,并在该目录下随意放入文件,最终会全部拷贝到我们的初始化项目中。

你可以将你设计的框架脚手架什么的放到这里面。

  1. 编译之后上传 NPM
1
2
npm run build
npm publish

浏览器缓存机制

发表于 2019-03-06

浏览器缓存机制

DNS 缓存

什么是DNS

全称 Domain Name System ,即域名系统。

万维网上作为域名和IP地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的IP数串。DNS协议运行在UDP协议之上,使用端口号53。

DNS解析

简单的说,通过域名,最终得到该域名对应的IP地址的过程叫做域名解析(或主机名解析)。

www.dnscache.com (域名) - DNS解析 -> 11.222.33.444 (IP地址)

DNS缓存

有dns的地方,就有缓存。浏览器、操作系统、Local DNS、根域名服务器,它们都会对DNS结果做一定程度的缓存。

DNS查询过程如下:

  1. 首先搜索浏览器自身的DNS缓存,如果存在,则域名解析到此完成。
  2. 如果浏览器自身的缓存里面没有找到对应的条目,那么会尝试读取操作系统的hosts文件看是否存在对应的映射关系,如果存在,则域名解析到此完成。
  3. 如果本地hosts文件不存在映射关系,则查找本地DNS服务器(ISP服务器,或者自己手动设置的DNS服务器),如果存在,域名到此解析完成。
  4. 如果本地DNS服务器还没找到的话,它就会向根服务器发出请求,进行递归查询。

CDN 缓存

什么是CDN

全称 Content Delivery Network,即内容分发网络。
摘录一个形象的比喻,来理解CDN是什么。

10年前,还没有火车票代售点一说,12306.cn更是无从说起。那时候火车票还只能在火车站的售票大厅购买,而我所在的小县城并不通火车,火车票都要去市里的火车站购买,而从我家到县城再到市里,来回就是4个小时车程,简直就是浪费生命。后来就好了,小县城里出现了火车票代售点,甚至乡镇上也有了代售点,可以直接在代售点购买火车票,方便了不少,全市人民再也不用在一个点苦逼的排队买票了。
简单的理解CDN就是这些代售点(缓存服务器)的承包商,他为买票者提供了便利,帮助他们在最近的地方(最近的CDN节点)用最短的时间(最短的请求时间)买到票(拿到资源),这样去火车站售票大厅排队的人也就少了。也就减轻了售票大厅的压力(起到分流作用,减轻服务器负载压力)。
用户在浏览网站的时候,CDN会选择一个离用户最近的CDN边缘节点来响应用户的请求,这样海南移动用户的请求就不会千里迢迢跑到北京电信机房的服务器(假设源站部署在北京电信机房)上了。

CDN缓存

关于CDN缓存,在浏览器本地缓存失效后,浏览器会向CDN边缘节点发起请求。类似浏览器缓存,CDN边缘节点也存在着一套缓存机制。CDN边缘节点缓存策略因服务商不同而不同,但一般都会遵循http标准协议,通过http响应头中的

1
Cache-control: max-age   //后面会提到

浏览器缓存(http缓存)

image
简单来说,浏览器缓存其实就是浏览器保存通过HTTP获取的所有资源,是浏览器将网络资源存储在本地的一种行为。

缓存的资源去哪里了?

你可能会有疑问,浏览器存储了资源,那它把资源存储在哪里呢?

memory cache

MemoryCache顾名思义,就是将资源缓存到内存中,等待下次访问时不需要重新下载资源,而直接从内存中获取。Webkit早已支持memoryCache。
目前Webkit资源分成两类,一类是主资源,比如HTML页面,或者下载项,一类是派生资源,比如HTML页面中内嵌的图片或者脚本链接,分别对应代码中两个类:MainResourceLoader和SubresourceLoader。虽然Webkit支持memoryCache,但是也只是针对派生资源,它对应的类为CachedResource,用于保存原始数据(比如CSS,JS等),以及解码过的图片数据。

disk cache

DiskCache顾名思义,就是将资源缓存到磁盘中,等待下次访问时不需要重新下载资源,而直接从磁盘中获取,它的直接操作对象为CurlCacheManager。

- memory cache disk cache
相同点 只能存储一些派生类资源文件 只能存储一些派生类资源文件
不同点 退出进程时数据会被清除 退出进程时数据不会被清除
存储资源 一般脚本、字体、图片会存在内存当中 一般非脚本会存在内存当中,如css等

因为CSS文件加载一次就可渲染出来,我们不会频繁读取它,所以它不适合缓存到内存中,但是js之类的脚本却随时可能会执行,如果脚本在磁盘当中,我们在执行脚本的时候需要从磁盘取到内存中来,这样IO开销就很大了,有可能导致浏览器失去响应。

三级缓存原理 (访问缓存优先级)

  1. 先在内存中查找,如果有,直接加载。
  2. 如果内存中不存在,则在硬盘中查找,如果有直接加载。
  3. 如果硬盘中也没有,那么就进行网络请求。
  4. 请求获取的资源缓存到硬盘和内存。

    浏览器缓存的分类

  5. 强缓存
  6. 协商缓存
    浏览器再向服务器请求资源时,首先判断是否命中强缓存,再判断是否命中协商缓存!

浏览器缓存的优点

  1. 减少了冗余的数据传输
  2. 减少了服务器的负担,大大提升了网站的性能
  3. 加快了客户端加载网页的速度

强缓存

浏览器在加载资源时,会先根据本地缓存资源的 header 中的信息判断是否命中强缓存,如果命中则直接使用缓存中的资源不会再向服务器发送请求。

这里的 header 中的信息指的是 expires 和 cahe-control.

Expires

该字段是 http1.0 时的规范,它的值为一个绝对时间的 GMT 格式的时间字符串,比如 Expires:Mon,18 Oct 2066 23:59:59 GMT。这个时间代表着这个资源的失效时间,在此时间之前,即命中缓存。这种方式有一个明显的缺点,由于失效时间是一个绝对时间,所以当服务器与客户端时间偏差较大时,就会导致缓存混乱。

Cache-Control

Cache-Control 是 http1.1 时出现的 header 信息,主要是利用该字段的 max-age 值来进行判断,它是一个相对时间,例如 Cache-Control:max-age=3600,代表着资源的有效期是 3600 秒。cache-control 除了该字段外,还有下面几个比较常用的设置值:

  • no-cache:需要进行协商缓存,发送请求到服务器确认是否使用缓存。
  • no-store:禁止使用缓存,每一次都要重新请求数据。
  • public:可以被所有的用户缓存,包括终端用户和 CDN 等中间代理服务器。
  • private:只能被终端用户的浏览器缓存,不允许 CDN 等中继缓存服务器对其缓存。
    Cache-Control 与 Expires 可以在服务端配置同时启用,同时启用的时候 Cache-Control 优先级高。

    协商缓存

当强缓存没有命中的时候,浏览器会发送一个请求到服务器,服务器根据 header 中的部分信息来判断是否命中缓存。如果命中,则返回 304 ,告诉浏览器资源未更新,可使用本地的缓存。
这里的 header 中的信息指的是 Last-Modify/If-Modify-Since 和 ETag/If-None-Match.

Last-Modify/If-Modify-Since

浏览器第一次请求一个资源的时候,服务器返回的 header 中会加上 Last-Modify,Last-modify 是一个时间标识该资源的最后修改时间。
当浏览器再次请求该资源时,request 的请求头中会包含 If-Modify-Since,该值为缓存之前返回的 Last-Modify。服务器收到 If-Modify-Since 后,根据资源的最后修改时间判断是否命中缓存。
如果命中缓存,则返回 304,并且不会返回资源内容,并且不会返回 Last-Modify。
缺点:
短时间(毫秒级)内资源发生了改变,Last-Modified 并不会发生变化。
周期性变化。如果这个资源在一个周期内修改回原来的样子了,我们认为是可以使用缓存的,但是 Last-Modified 可不这样认为,因此便有了 ETag。

ETag/If-None-Match

与 Last-Modify/If-Modify-Since 不同的是,Etag/If-None-Match 返回的是一个校验码。ETag 可以保证每一个资源是唯一的,资源变化都会导致 ETag 变化。服务器根据浏览器上送的 If-None-Match 值来判断是否命中缓存。
与 Last-Modified 不一样的是,当服务器返回 304 Not Modified 的响应时,由于 ETag 重新生成过,response header 中还会把这个 ETag 返回,即使这个 ETag 跟之前的没有变化。
Last-Modified 与 ETag 是可以一起使用的,服务器会优先验证 ETag,一致的情况下,才会继续比对 Last-Modified,最后才决定是否返回 304。

总结

当浏览器再次访问一个已经访问过的资源时,它会这样做:

  1. 看看是否命中强缓存,如果命中,就直接使用缓存了。
  2. 如果没有命中强缓存,就发请求到服务器检查是否命中协商缓存。
  3. 如果命中协商缓存,服务器会返回 304 告诉浏览器使用本地缓存。
  4. 否则,返回最新的资源。

hc

前端学习总结

5 日志
1 标签
© 2020 hc
由 Hexo 强力驱动
|
主题 — NexT.Mist v5.1.4