碎碎念

前些天,我对网站的样式进行了优化,包括关于页面、顶栏特效和左上角菜单等。然而,每次修改后,都需要手动按 Ctrl+F5 强制刷新缓存,虽然操作不复杂,但有些朋友可能并不知道 Ctrl+F5 可以强制刷新,导致页面样式异常。同时,浏览器会缓存样式文件,虽然缓存了内容,却没有得到充分利用。因此,我决定配置 SW 来实现更精细的控制。

恰好,空梦 大佬开发了一个功能更丰富、更加可控的 SWPP。相比于传统 SWSWPP 支持 增量更新URL 竞速备用 URL 等强大特性。于是,我连夜缠着大佬帮我配置,最终顺利实现,真的非常感谢空梦的帮助!

可能你对这些概念还不太熟悉?别急!接下来我会逐一讲解。本篇教程的目标是让大家能够 零门槛 配置 SWPP,可能不会涉及一些晦涩的知识,当然我也不是很懂,如果你对底层原理感兴趣,欢迎阅读官方文档!

简单介绍

原生SW

可能很多人对 SW(Service Worker)并不了解,甚至没听说过它的作用。简单来说,SW 是一种运行在浏览器后台的独立脚本,能够拦截和控制页面的网络请求,实现资源缓存、后台同步、推送通知等功能。

在默认情况下呢,浏览器会根据 HTTP 头的缓存策略来决定资源的缓存时间,但这些缓存并不受开发者的直接控制,而是由浏览器自行管理。而 SW 允许开发者通过编写脚本,精确控制哪些资源应该缓存、缓存多久、何时更新等,提供了更灵活的缓存管理能力。

你可以尝试在你的网站上修改一个CSS样式,然后推送到网站上,在缓存等全部刷新后,进入网站,样式仍然无法生效,需要手动强制刷新才能显示最新的效果,这就是浏览器的默认缓存,我们所要做的就是合理利用这部分内容,使网站更新更及时,更加节省流量,加载速度更快。

下面是原生SW的官方文档,你会发现比SWPP的理论性知识还枯燥难懂……

通常SW主要用于以下几个场景:

  • 离线访问:即使用户断网,已缓存的资源仍然可以正常加载,提升可用性。
  • 加速页面加载:通过缓存静态资源,减少网络请求,提高页面加载速度。
  • 网络请求代理:拦截请求并根据规则决定是否从缓存返回或从服务器获取数据。
  • 后台任务处理:支持推送通知、后台数据同步等功能,提升用户体验。

尽管 SW 具备很强的可控性,但它也可能带来一些潜在问题。如果 SW 配置不合理,可能会导致严重的缓存管理失误,影响用户体验。比如缓存污染 可能会使用户始终加载旧版本的资源,即使服务器已经更新,仍然无法获取最新内容。此外,更新滞后 也是经常遇到的问题,默认情况下,新的 SW 需要等到旧 SW 释放后才会生效,这可能导致网站更新无法及时反映到用户端,而SWPP就解决了这些痛点,我们可以自行定义缓存过期时间,并且可以实现增量更新,也就是网站更新后,前端检测自动刷新相关的文件缓存,实现高度可用。

SWPP优点

这里图方便,我直接给官方文档里的区别图搬过来啦,后面会大概讲解其中的主要内容。

\swpp@3swpp@2hexo-offline
本地缓存✔️✔️✔️
缓存增量更新✔️✔️
缓存过期时间✔️✔️
缓存大小限制✔️
预缓存✔️
Request 篡改✔️✔️
URL 竞速✔️✔️
备用 URL✔️✔️
204 阻塞响应✔️✔️
逃生门✔️✔️
请求合并✔️
高度自由✔️✔️
更新活跃停止维护超过两年没有更新
  1. 本地缓存:最基本的功能,将网站上的文件缓存到Cache中,提高访问速度,高度利用浏览器的默认缓存机制,你可以按照下图寻找该站的缓存:

    本站缓存

  2. 增量更新:通常情况下,缓存更新的方式是完全替换旧的缓存,而增量更新允许只替换部分资源,比如本次更新我修改了index.css,在构建的过程中,会对比其前后的哈希值,如果该文件哈希值发生问题,就会更新该文件,所以**swpp命令建议在压缩后执行**,因为压缩的结果可能每次都不一样,会导致误检测所有文件都需要更新。

  3. 缓存过期时间缓存大小限制:忒好理解了,跳过跳过~

  4. 预缓存:预缓存指的是在 SW 安装时就提前缓存一部分资源,以便离线时可以直接加载。不过,对于经常更新的网站,预缓存可能会导致旧资源无法及时清理,因此不一定适合所有场景。

  5. Request篡改:允许开发者拦截和修改浏览器的请求,比如动态修改 URL、添加自定义请求头等。这对于调试、数据代理和 CDN 适配等场景非常有用,比如我们可以通过修改jsdelivr的请求到国内现有镜像站,加快访问速度。

  6. 备用URL:当某个资源加载失败时,备用URL可以提供一个替代方案,避免页面崩溃。例如,如果主服务器宕机,可以自动切换到备选服务器加载资源,某个镜像站突然访问不了,SW可以自动切换到另一个镜像站进行请求,实现高度稳定性。

  7. 204阻塞响应:反正我是没懂,下面是GPT的解释,该功能用于阻止不必要的请求,减少服务器负担。

  8. 逃生门:当你的SW实现错误导致无法发送消息,且DOM被错误的永久缓存,那么靠普通手段是完全没有办法解决这个死循环的,逃生门的作用就是触发时强制更新缓存来解开这个死局。

  9. 请求合并:允许多个相似的请求合并成一个,减少网络请求数量,不过目前这个功能基本上都是在网络层更好实现,比兔合并TCP请求,减少握手挥手次数,这样看来,前端的实现就变得可有可无了。

以上就是大部分的内容,我个人感觉是可以简单的理解的,虽然半知半解但是至少懂了这个是干嘛的!那么我们就可以进入下一部分啦!开始配置!

开始配置

插件安装

swpp还在开发阶段,目前更新的均为开发版本的内容,但是经过前些天的更新,本站的测试,开发版基本上没有任何问题,所以该教程将基于最新版本的SWPP进行讲解。

首先下载包,这里我们选择Hexo插件,所以除了必要的后端包,还需要Hexo-swpp插件,在博客根目录执行以下命令:

1
2
npm install swpp-backends@3.0.0-alpha.906 --save
npm install hexo-swpp@4.0.0-alpha.131 --save

下面开始配置,在Hexo配置文件_config.yml中添加以下内容,注意不是主题配置文件:

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
swpp:
# 是否启用,默认 false
enable: true
# 配置文件路径,以 `/` 结尾表示加载指定文件夹下的所有文件,注意文件夹中只能有配置文件,不能有其它文件及文件夹
config_path: 'swpp.config.ts'
# 是否生成 sw
serviceWorker: true
# 是否向所有 HTML 插入注册 sw 的代码
auto_register: true
# 是否生成 DOM 端的 JS 文件并在 HTML 中插入 script
gen_dom: true
# 是否追踪链接,默认 false
# ⚠️警告!!!未启用链接追踪时不要使用永久缓存,否则会导致被缓存的数据永远不会更新,在您不再使用该资源时,缓存也不会被删除!!!
track_link: true
# 生成的 diff 文件的路径(可以是绝对路径也可以是相对路径,使用相对路径时相对于网站发布目录),留空表示不生成(默认为 null)
gen_diff: './diff.json'
# 是否在执行 hexo deploy 时自动执行 swpp 指令
# auto_exec: false
# 检查更新的网址,默认 "https://registry.npmjs.org",注意不能以斜杠结尾
npm_url: 'https://registry.npmmirror.com'
#
# 排序规则。
# 该配置项是为了对 hexo 中的一些变量进行排序,避免每次生成 HTML 时由于这些变量的顺序变动导致生成结果不完全相同。
# 示例:
# ```yaml
# # 下面给出的值为插件的缺省值,用户设置该项不会直接覆盖这些值,只有用户也声明 posts、pages 或 tags 时才会覆盖对应的值。
# swpp:
# sort_rules:
# posts: 'title'
# pages: 'title'
# tags: 'name'
# ```
# 其中 key 值为要排序的变量的名称,value 为变量排序时的依据,
# 填 false 表示禁用该项排序,填 true 表示以 value 本身为键进行排序,填字符串表示以 value[tag] 为键进行排序。
# sort_rules:

以上是本站配置,其余配置请按照自身需要去开,建议和本站一致,备注写的很详细,不太懂的按照本站配置即可。

其中track_link是增量更新的配置,如果设置为false将严格按照缓存时间进行过期,不会自动检测并更新文件,建议开启。

插件配置

注意

注意,错误的缓存配置可能会导致部分该阶段访问的用户无法查看更新内容,请小心谨慎的配置!!!

SWPP还需要一个配置文件,来控制我们所需要的功能,在博客根目录下创建文件swpp.config.ts,当然也支持其他类型的配置文件比如:tsjsmtsctsmjscjs,完全没区别,这里我以ts为例。

配置文件说明

由于配置文件配置难度较高,为了大家更为方便的实现功能,我建议直接基于我的配置文件实现,该文件包含大部分实用功能,如果有更高的要求可以自行查看文档进行适配。

在文件中写入以下内容:

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
import {
defineConfig
} from 'swpp-backends'

defineConfig({
compilationEnv: {
DOMAIN_HOST: new URL('https://blog.liushen.fun'),
SERVICE_WORKER: "sw",
JSON_HTML_LIMIT: 10,
isStable: (url: URL) => {
return [
/^(https?:\/\/|\/\/)(cdn|fastly)\.jsdelivr\.net\/npm\/.*@\d+\.\d+\.\d+\//,
/^(https?:\/\/|\/\/)jsd\.example\.com\/.*@\d+\.\d+\.\d+\//,
/^(https?:\/\/|\/\/)cdn\.jsdmirror\.com\/.*@\d+\.\d+\.\d+\//,
/^(https?:\/\/|\/\/)cdn\.staticfile\.org\/.*\/\d+\.\d+\.\d+\//,
/^(https?:\/\/|\/\/)lf\d+-cdn-tos\.bytecdntp\.com\/.*\/\d+\.\d+\.\d+\//,
/^(https?:\/\/|\/\/)npm\.elemecdn\.com\/.*@\d+\.\d+\.\d+\//
].some(it => it.test(url.href))
},
VERSION_LENGTH_LIMIT: 512,
NETWORK_FILE_FETCHER: {
referer: "https://blog.liushen.fun",
getStandbyList(url: string | URL): (string | URL)[] {
if (typeof url === 'string') url = new URL(url)
if (url.hostname === 'npm.elemecdn.com') {
return [`https://fastly.jsdelivr.net${url.pathname}`]
}
return [url]
}
}
},

crossEnv: {
CACHE_NAME: "BlogCache",
VERSION_PATH: "https://id.v3/",
ESCAPE: 15,
},

runtimeDep: {
getStandbyRequests: (request: Request): {t: number, l: () => Request[]} | void => {
const srcUrl = request.url
const {host, pathname} = new URL(srcUrl)
// noinspection SpellCheckingInspection
const commonCdnList = ['jsd.example.com', 'cdn.jsdmirror.com', 'fastly.jsdelivr.net']
// noinspection SpellCheckingInspection
const elme = 'npm.elemecdn.com'
const urlMapper = (it: string) => new Request(it, request)
if (host === elme) {
return {
t: 2000,
l: () => [...commonCdnList.map(it => `https://${it}/npm${pathname}`)].map(urlMapper)
}
}
if (host === 'jsd.example.com') {
commonCdnList.splice(0, 1)
return {
t: 2000,
l: () => [...commonCdnList.map(it => `https://${it}${pathname}`)].map(urlMapper)
}
}
}
},

crossDep: {
matchCacheRule: {
runOnBrowser: (url: URL) => {
let { host, pathname } = url;

// 处理省略index.html的情况
if (pathname.endsWith('/')) pathname += 'index.html';

// 仅仅对于blog.liushen.fun 处理 html 和 json
if (host.endsWith('blog.liushen.fun')) {
if (pathname.endsWith('.json')) return 3600000; // 1 hour
if (pathname.endsWith('.html')) return false; // 暂不缓存
if (pathname.endsWith('.webp') || pathname.endsWith('.jpg') || pathname.endsWith('.png')) return 43200000; // 12 hours
}
if (/\.(js|css|woff2|woff|ttf|cur)$/.test(url.pathname)) return 172800000; // 2 days

// return ((url.host.endsWith('blog.liushen.fun') && /(\/|\.json)$/.test(url.pathname)) || /\.(js|css|woff2|woff|ttf|cur)$/.test(url.pathname)) ? 86400000 : false
},
runOnNode(url: URL) {
// @ts-ignore
return this.runOnBrowser(url)
}
}
},
})

以上是我的配置,下面我会详细讲解需要修改哪里。

DOMAIN_HOST

必须改的部分,这个应该都能理解,所以这里我就不讲了。

isStable

你需要改的首先就是isStable部分,这部分的内容是判断该内容是否为永久不变的内容,比如jsdelivr加了版本号的文件,一般是不会变动的,这就是稳定内容,所以请自行替换jsd.example.com一行的匹配或者直接删掉,这里的通配符尽量覆盖贵站的所有JSD资源。

1
2
3
4
5
6
7
8
9
10
isStable: (url: URL) => {
return [
/^(https?:\/\/|\/\/)(cdn|fastly)\.jsdelivr\.net\/npm\/.*@\d+\.\d+\.\d+\//,
/^(https?:\/\/|\/\/)jsd\.example\.com\/.*@\d+\.\d+\.\d+\//,
/^(https?:\/\/|\/\/)cdn\.jsdmirror\.com\/.*@\d+\.\d+\.\d+\//,
/^(https?:\/\/|\/\/)cdn\.staticfile\.org\/.*\/\d+\.\d+\.\d+\//,
/^(https?:\/\/|\/\/)lf\d+-cdn-tos\.bytecdntp\.com\/.*\/\d+\.\d+\.\d+\//,
/^(https?:\/\/|\/\/)npm\.elemecdn\.com\/.*@\d+\.\d+\.\d+\//
].some(it => it.test(url.href))
},

这里我建议大家不要在配置文件中修改某个文件的地址,而是统一替换,下面文章中有教程,欢迎阅读,注意原文中的智云加速可能无法使用,请自行替换为文章头部其他的镜像站,使用和配置方式是完全一样的,只需要替换链接即可。

推荐这样配置的原因是可以加速所有的静态,不需要一个个配置,非常整齐,并且比如Butterfly主题会对该资源所在站点进行预加载,可以稍微加快访问速度,如果一个个配置可能无法达到良好的效果,再者就是,在SWPPisStable函数中,可以更少的通配符即可匹配所有的稳定资源地址。

Preconnect标签

当然,还是按照自己的站点自行配置,如果有问题欢迎在评论区讨论!

referer

如果你使用的是自己反代的一些资源,可能会挂上防盗链防止盗刷,会导致后端对比文件差异时无法拉取到文件导致报错,所以SWPP提供了Referer选项,按照自身要求填写即可。

getStandbyRequests

该项用于启用备用 URL,返回任意转换为 false 值均表示对于给定的资源不启用备用 URL,这里我建议开启,指向一些备用jsd镜像站,这里我仍然使用jsd.example.com作为jsd镜像站为举例,自行替换即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
getStandbyRequests: (request: Request): {t: number, l: () => Request[]} | void => {
const srcUrl = request.url
const {host, pathname} = new URL(srcUrl)
// noinspection SpellCheckingInspection
const commonCdnList = ['jsd.example.com', 'cdn.jsdmirror.com', 'fastly.jsdelivr.net']
// noinspection SpellCheckingInspection
const elme = 'npm.elemecdn.com'
const urlMapper = (it: string) => new Request(it, request)
if (host === elme) {
return {
t: 2000,
l: () => [...commonCdnList.map(it => `https://${it}/npm${pathname}`)].map(urlMapper)
}
}
if (host === 'jsd.example.com') {
commonCdnList.splice(0, 1)
return {
t: 2000,
l: () => [...commonCdnList.map(it => `https://${it}${pathname}`)].map(urlMapper)
}
}
}

matchCacheRule

该项用于判断一个资源是否需要被前端缓存,其中各个返回值的含义如下:

  • false null undefined0:表示不需要缓存。
  • 正数:表示需要缓存,缓存类型为定时缓存,单位为毫秒,超过指定时间后缓存自动失效。
  • INFINITE_CACHE:表示需要缓存,缓存类型为永久缓存,仅能通过增量更新失效。
警告

请勿返回负数!使用无限期缓存请返回 INFINITE_CACHEINFINITE_CACHE 是一个 Symbol 类型的常量。

无限期缓存谨慎使用,可能造成没必要的资源永久堆积。

比如以下例子,对于本站内的资源,我分别配置了jsonhtmlwebp|jpg|png图片的缓存,防止因为永久缓存导致缓存量过大,对于站外的静态资源,缓存时间我设置为了两天,注意这里的单位是毫秒ms,如果你想修改,请按照要求自行填写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
matchCacheRule: {
runOnBrowser: (url: URL) => {
let { host, pathname } = url;

// 处理省略index.html的情况
if (pathname.endsWith('/')) pathname += 'index.html';

// 仅仅对于blog.liushen.fun 处理 html 和 json
if (host.endsWith('blog.liushen.fun')) {
if (pathname.endsWith('.json')) return 3600000; // 1 hour
if (pathname.endsWith('.html')) return false; // 暂不缓存
if (pathname.endsWith('.webp') || pathname.endsWith('.jpg') || pathname.endsWith('.png')) return 43200000; // 12 hours
}
if (/\.(js|css|woff2|woff|ttf|cur)$/.test(url.pathname)) return 172800000; // 2 days

// return ((url.host.endsWith('blog.liushen.fun') && /(\/|\.json)$/.test(url.pathname)) || /\.(js|css|woff2|woff|ttf|cur)$/.test(url.pathname)) ? 86400000 : false
},
runOnNode(url: URL) {
// @ts-ignore
return this.runOnBrowser(url)
}
}

这样,我们的swpp应该就配置成功了,注意,部署的过程中,无论是Github action也好,Vercel也罢,应该都需要执行hexo generate或者npm run build命令生成静态文件,在这两个命令后,紧跟一个hexo swpp命令,使其可以在构建静态文件后自动检测是否有文件需要更新并将检测结果保存到了静态文件中的/swpp/路径下,比如本站的update.json文件,感兴趣自行点击查看。

比如github action构建的方式,可以尝试在这里添加一个hexo swpp的命令以达到效果:

github action实现swpp构建

注意该命令需要在压缩命令之前执行。

第一次执行hexo swpp命令可能出现问题,是正常现象,如下:

hexo swpp第一次

如果正常构建完成后,swpp在下次构建中会检测所有文件的哈希值,对比是否有改动,然后选择性增量更新,这样,我们的SWPP就实现成功啦!

查看效果

部署到前端后,你可以F12打开控制台,点击应用,查看缓存空间,不出所料,会出现一个名为BlogCache的内容,后面有每一项的缓存时间,你可以尝试更新一下css然后再到前端刷新,不出所料,在博客的/swpp/update.json会看到对应的修改文件的后半部分,在前端你也会看到CSS的缓存时间更新了,这就是增量更新生效了,当然缓存刷新有一定的CD,默认为十分钟,可以尝试部署成功后,十分钟后再测试。

缓存空间位置

PWA实现

PWA(渐进式 Web 应用,Progressive Web App)是一种让网站拥有接近原生应用体验的技术。它结合了网页的灵活性和App的优势,使得用户可以像使用手机应用一样访问和操作网站,而不需要额外下载和安装。

一个PWA网站可以在离线状态下继续运行,这得益于Service Worker技术,它能够缓存关键资源,使网页即使在没有网络的情况下也能正常加载。与此同时,PWA还可以支持安装到桌面或手机主屏幕,用户打开时不会看到浏览器的UI,而是像独立的App一样运行,这种体验可以增强用户的沉浸感。PWA还可以通过优化缓存和请求管理,让页面加载更快,减少流量消耗,提高交互流畅度。

在功能扩展方面,PWA还能支持推送通知,允许网站主动向用户发送消息,增强互动性。不过,这一功能需要额外的服务器支持。

当然以上功能需要和缓存策略结合,比如本站并没有缓存html文件,所以可能达不到离线运行的效果,当然你可以自行定制化。

PWA应用

你的站点已经通过SWPP实现了缓存优化和更细粒度的控制,这正是PWA的重要组成部分。如果进一步配置 manifest.json,即可实现PWA

头部信息

这里我们以Hexo-theme-butterfly为示例,btf中很好的集成了PWA,首先,修改主题配置文件的PWA部分配置:

1
2
3
4
5
6
7
8
9
10
# PWA
# 查看 https://github.com/JLHwung/hexo-offline
# ---------------
pwa:
enable: true
manifest: /manifest.json
apple_touch_icon: /apple-touch-icon.png
favicon_32_32: /config/img/pwa/favicon-32x32.png
favicon_16_16: /config/img/pwa/favicon-16x16.png
mask_icon: /config/img/pwa/favicon-mask.svg

图标注意一一对应,mask.svg实测似乎并没用到哪里,所以其实可有可无,可以先配置,svg任意放置一个即可。

manifest.json

source文件夹下创建manifest.json,写入以下内容:

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
{
"lang": "en",
"name": "清羽飞扬",
"short_name": "清羽飞扬",
"description": "blog.liushen.fun",
"theme_color": "#242424",
"background_color": "#242424",
"display": "standalone",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "/config/img/pwa/favicon-36x36.png",
"sizes": "36x36",
"type": "image/png"
},
{
"src": "/config/img/pwa/favicon-48x48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "/config/img/pwa/favicon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/config/img/pwa/favicon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/config/img/pwa/favicon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/config/img/pwa/favicon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/config/img/pwa/favicon-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "/config/img/pwa/favicon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/config/img/pwa/favicon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

修改网站标题和网站url,图标使用相对位置定义,如果有部分图标尺寸不存在,可以删除,但是尽量全部配置,不要使用网络地址!!!

图标展示

我也不知道16x16是为了实现啥,完全看不清了都!

如此配置好后,再次更新网站,你应该就可以看到地址栏最右侧的安装按钮,这表明网站配置完毕,如果无法看到,可以右键删除缓存,重新生成一次,相信可以做到理想的效果。

教程结束!

总结

在空梦大佬的帮助下,我成功配置了SWPP,让网站的缓存管理更加灵活,访问速度更快,同时也实现了一定程度的离线支持。现在,即使改动了样式或脚本,也不用再担心用户访问时加载的还是旧版本的资源,体验提升了不少。

不过,SW这个东西确实是把双刃剑,配置得好,网站快如闪电,访问体验拉满,尤其适合一些部署在Vercel或者CF Page的站长,缓存到本地可以极大的解决源站访问缓慢的问题;但如果配置不当,可能会导致用户长时间看不到最新内容,甚至页面直接炸裂,怎么刷新都没用。有些极端情况下,错误的 SW 逻辑甚至可能让用户卡在旧版本里无法更新,除非手动清除缓存,影响体验不说,还容易劝退一部分访客。所以,虽然SW很香,但在使用时必须保持谨慎,确保更新机制合理,避免给用户带来困扰。

最后,感谢空梦大佬的帮助!

参考文章

每日一图

图片来自哲风壁纸

夕阳,玫瑰,冷寂,孤独