基于Vercel搭建免费图床

前言

由于博客中的图片都是引用图床中的图片,自己搭建图床可能涉及到租赁的服务器到期,服务迁移,或者流量被盗刷等不可抗力因素等,免费图床又可能随时失效,面对以上场景,我一直试图尝试寻找一种免费的解决办法作为备选,使用GitHub图床是其中的一种,但是 Github 仓库大小达到 1G 的时候会有人工审查,如果发现你将 Github 仓库作为图床使用可能会被封禁仓库。最主要的是国内访问速度太慢,之前的免费CDN好像失效了,后来我发现可以使用Vercel搭配自定义域名实现。

实现原理

Vercel可以一键导入Github上的项目,可以利用在GitHub上备份的图片文件搭建一个简单的图床程序,直接从GitHub导入项目,一键部署即可。

注意事项

  1. 需要在项目根目录上传一个index.html入口文件,否则无法访问图片资源。项目结构类似下面
1
2
3
4
5
6
7
blogpic/
├── index.html
└── public/
└── 2024/
└── 08/
└── 28/
└── xxx.png
  1. 由于Vercel提供的域名在国内无法访问,需要绑定自定义域名

  2. 使用PicGo配置GitHub图床时,将原来的加速CDN修改为自定义域名

    1
    2
    3
    4
    # 修改前
    https://cdn.jsdelivr.net/gh/Shiguang-coding/blogpic@master
    # 修改后
    https://img.shiguang666.eu.org

image-20240828150309876

优缺点

优点

  1. 可以关联GitHub账户,GitHub提交后自动部署,结合PicGo使用非常方便

  2. 访问速度还不错,不怕刷流量

  3. 方便迁移,采用自定义域名,迁移时直接批量替换图片路径即可

    图床文件迁移可参考 Gitee图床被封!如何实现无缝图床转移?

缺点

  1. 一次上传不能及时显示,如果你要求上传之后及时显示,不建议使用这种方式,这种方式每次都是静态部署网站,平均速度在10s以内。
  2. 有每日部署频率限制,貌似是100次左右,如果你有大量图片上传的话,建议先上传到github,再批量替换文件链接 ,该方案只作为主图床失效时临时备用方案,GitHub使用PicGo上传频率过高也会影响GitHub贡献地图的数据。
  3. 需要有一个自己的域名(vercel 部署的自带域名国内无法访问)。
  4. 因为 Github 存储库的限制,单文件最大 25MB,超出会提示文件过大。

本地预览

Demo见GitHub:https://github.com/Shiguang-coding/vercel-img

让AI帮我写了个简单的程序,实现图床在线预览的功能,具体效果如下:

img

但是部署到Vercel后无法实现这个效果,理论上是可以的,有了解的小伙伴可以留言指点下。

1、项目根目录创建public 文件夹

将所有图片文件移动到public目录内

之所以不把public目录直接上传到GitHub,是因为在使用PicGo时默认导入到剪切板的路径是 设定的自定义域名+ 设定存储路径,而预览图片时是不需要加public这一层目录的,所以会导致上传后剪切板自动带出的图片路径无法访问。

2、在public目录下创建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
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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>時光图床</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
background-color: #f9f9f9;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
}
.folder, .image {
margin: 10px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
text-align: center;
width: 100%;
max-width: 300px;
background-color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.folder:hover, .image:hover {
background-color: #f0f0f0;
transform: translateY(-2px);
}
.image img {
max-width: 100%;
max-height: 200px;
cursor: pointer;
border-radius: 5px;
}
.modal {
display: none;
position: fixed;
z-index: 1;
padding-top: 100px;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgb(0,0,0);
background-color: rgba(0,0,0,0.9);
}
.modal-content {
margin: auto;
display: block;
width: 80%;
max-width: 700px;
border-radius: 5px;
}
.close {
position: absolute;
top: 15px;
right: 35px;
color: #f1f1f1;
font-size: 40px;
font-weight: bold;
transition: 0.3s;
}
.close:hover, .close:focus {
color: #bbb;
text-decoration: none;
cursor: pointer;
}
@media (min-width: 600px) {
.container {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.folder, .image {
width: 45%;
max-width: none;
}
}
@media (min-width: 900px) {
.folder, .image {
width: 30%;
}
}
</style>
</head>
<body>
<h1>時光图床</h1>
<div class="container" id="container"></div>

<!-- 模态框 -->
<div id="myModal" class="modal">
<span class="close">&times;</span>
<img class="modal-content" id="img01">
<div id="caption"></div>
</div>

<script>
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('container');
const modal = document.getElementById('myModal');
const modalImg = document.getElementById('img01');
const close = document.getElementsByClassName('close')[0];

let currentPath = '';

function loadFolders(folders, path = '') {
container.innerHTML = ''; // 清空容器
folders.forEach(folder => {
if (folder.images && folder.images.length > 0) {
folder.images.forEach(image => {
const imageDiv = document.createElement('div');
imageDiv.className = 'image';
const img = document.createElement('img');
img.src = `/${image}`;
img.alt = image;
img.onclick = function() {
modal.style.display = 'block';
modalImg.src = this.src;
history.pushState({ path: currentPath, image: image }, '', `/${image}`);
};
imageDiv.appendChild(img);
container.appendChild(imageDiv);
});
} else {
const folderDiv = document.createElement('div');
folderDiv.className = 'folder';
folderDiv.textContent = folder.name;
folderDiv.onclick = function() {
currentPath = `${path}${folder.name}/`;
history.pushState({ path: currentPath }, '', currentPath);
fetch(`/api/files/${currentPath}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => loadFolders(data, currentPath))
.catch(error => console.error('Error fetching files:', error));
};
container.appendChild(folderDiv);
}
});
}

window.addEventListener('popstate', function(event) {
if (event.state && event.state.path) {
currentPath = event.state.path;
fetch(`/api/files/${currentPath}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => loadFolders(data, currentPath))
.catch(error => console.error('Error fetching files:', error));
}
if (event.state && event.state.image) {
modal.style.display = 'block';
modalImg.src = `/${event.state.image}`;
}
});

fetch('/api/files')
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
currentPath = '';
history.replaceState({ path: currentPath }, '', currentPath);
loadFolders(data);
})
.catch(error => console.error('Error fetching files:', error));

close.onclick = function() {
modal.style.display = 'none';
history.pushState({ path: currentPath }, '', currentPath);
};

window.onclick = function(event) {
if (event.target == modal) {
modal.style.display = 'none';
history.pushState({ path: currentPath }, '', currentPath);
}
};
});
</script>
</body>
</html>

3、在项目根目录创建 package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"name": "blogpic",
"version": "1.0.0",
"description": "使用Vercel一键部署实现图床功能",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "shiguang-coding",
"license": "ISC",
"dependencies": {
"express": "^4.19.2",
"fs": "^0.0.1-security",
"path": "^0.12.7"
}
}

4、在项目根目录创建 server.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
const express = require('express');
const fs = require('fs');
const path = require('path');

const app = express();
// const port = 8080;
const port = process.env.PORT || 8080;

app.use(express.static('public'));

// 获取文件夹和图片信息
app.get('/api/files', (req, res) => {
const baseDir = path.join(__dirname, 'public');
fs.readdir(baseDir, { withFileTypes: true }, (err, files) => {
if (err) {
return res.status(500).json({ error: 'Unable to scan directory' });
}
const result = [];
files.forEach(file => {
if (file.isDirectory()) {
result.push({
name: file.name,
images: []
});
}
});
res.json(result);
});
});

// 获取指定文件夹的图片信息
app.get('/api/files/:folder(*)', (req, res) => {
const folderPath = path.join(__dirname, 'public', req.params.folder);
fs.readdir(folderPath, { withFileTypes: true }, (err, files) => {
if (err) {
return res.status(500).json({ error: 'Unable to scan directory' });
}
const result = [];
files.forEach(file => {
if (file.isDirectory()) {
result.push({
name: file.name,
images: []
});
} else if (file.isFile() && file.name.match(/\.(jpg|jpeg|png|gif)$/i)) {
result.push({
name: '',
images: [`${req.params.folder}/${file.name}`]
});
}
});
res.json(result);
});
});

app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});

5、安装和运行

前提条件

确保你已经安装了 Node.jsnpm

安装依赖

1
npm install

运行程序

1
node server.js

运行后,打开浏览器访问 http://localhost:8080,即可看到图床应用。

项目结构

1
2
3
4
5
your-project/
├── public/
│ └── index.html
├── server.js
└── package.json

批量替换图片路径

图床更换后可以借助工具如VS Code进行一键批量替换图片路径

image-20241214172203152

图片测试

下面三张图分别是使用PicGo上传GitHub图床未设定自定义域名,设置 jsdelivr CDN, 和 Vercel部署 的图片,为方便后期查看图床加载速度以及是否失效。

未设定自定义域名:

GitHub

jsdelivr :

jsdelivr

Vercel :

Vercel

参考

谢谢你,鱼皮!15分钟搭建vercel+github+picgo个人免费图床

使用Github+Vercel搭建图床并通过自定义域名进行加速你的视频或者图片

使用 Github 搭建图床并通过 Vercel 加速访问