Service Worker控制缓存实战

);

});

安装时,sw就开始缓存文件了,会检查所有文件的缓存状态,如果都已经缓存了,则安装成功,进入下一阶段。

activated

如果是第一次加载sw,在安装后,会直接进入activated阶段,而如果sw进行更新,情况就会显得复杂一些。流程如下:

首先老的sw为A,新的sw版本为B。B进入install阶段,而A还处于工作状态,所以B进入waiting阶段。只有等到A被terminated后,B才能正常替换A的工作。

这个terminated的时机有如下几种方式:

1、关闭浏览器一段时间;

2、手动清除serviceworker;

3、在sw安装时直接跳过waiting阶段

//service worker安装成功后开始缓存所需的资源

self.addEventListener(‘install’, function(event) {

//跳过等待过程

self.skipWaiting();

});

然后就进入了activated阶段,激活sw工作。

activated阶段可以做很多有意义的事情,比如更新存储在cache中的key和value:

var CACHE_PREFIX = ‘cms-sw-cache’;

var CACHE_VERSION = ‘0.0.20’;

/**

* 找出对应的其他key并进行删除操作

* @returns {*}

*/

function deleteOldCaches() {

return caches.keys().then(function (keys) {

var all = keys.map(function (key) {

if (key.indexOf(CACHE_PREFIX) !== -1 && key.indexOf(CACHE_VERSION) === -1){

console.log(‘[SW]: Delete cache:’ + key);

return caches.delete(key);

}

});

return Promise.all(all);

});

}

//sw激活阶段,说明上一sw已失效

self.addEventListener(‘activate’, function(event) {

event.waitUntil(

// 遍历 caches 里所有缓存的 keys 值

caches.keys().then(deleteOldCaches)

);

});

idle

这个空闲状态一般是不可见的,这种一般说明sw的事情都处理完毕了,然后处于闲置状态了。

浏览器会周期性的轮询,去释放处于idle的sw占用的资源。

fetch

该阶段是sw最为关键的一个阶段,用于拦截代理所有指定的请求,并进行对应的操作。

所有的缓存部分,都是在该阶段,这里举一个简单的例子:

//监听浏览器的所有fetch请求,对已经缓存的资源使用本地缓存回复

self.addEventListener(‘fetch’, function(event) {

event.respondWith(

caches.match(event.request)

.then(function(response) {

//该fetch请求已经缓存

if (response) {

return response;

}

return fetch(event.request);

}

)

);

});

生命周期大概讲清楚了,我们就以一个具体的例子来说明下原生的serviceworker是如何在生产环境中使用的吧。

举个栗子

我们可以以网易新闻的wap页为例,其针对不怎么变化的静态资源开启了sw缓存,具体的sw.js逻辑和解读如下:

‘use strict’;

//需要缓存的资源列表

var precacheConfig = [

[“https://static.ws.126.net/163/wap/f2e/milk_index/bg_img_sm_minfy.png”,

“c4f55f5a9784ed2093009dadf1e954f9”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/change.png”,

“9af1b102ef784b8ff08567ba25f31d95”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/icon-download.png”,

“1c02c724381d77a1a19ca18925e9b30c”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/icon-login-dark.png”,

“b59ba5abe97ff29855dfa4bd3a7a9f35”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/icon-refresh.png”,

“a5b1084e41939885969a13f8dbc88abd”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/icon-video-play.png”,

“065ff496d7d36345196d254aff027240”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/icon.ico”,

“a14e5365cc2b27ec57e1ab7866c6a228”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.eot”,

“e4d2788fef09eb0630d66cc7e6b1ab79”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.svg”,

“d9e57c341608fddd7c140570167bdabb”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.ttf”,

“f422407038a3180bb3ce941a4a52bfa2”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.woff”,

“ead2bef59378b00425779c4ca558d9bd”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/index.5cdf03e8.js”,

“6262ac947d12a7b0baf32be79e273083”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/index.bc729f8a.css”,

“58e54a2c735f72a24715af7dab757739”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-bohe.png”,

“ac5116d8f5fcb3e7c49e962c54ff9766”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-mail.png”,

“a12bbfaeee7fbf025d5ee85634fca1eb”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-manhua.png”,

“b8905b119cf19a43caa2d8a0120bdd06”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-open.png”,

“b7cc76ba7874b2132f407049d3e4e6e6”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-read.png”,

“e6e9c8bc72f857960822df13141cbbfd”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/logo-site.png”,

“2b0d728b46518870a7e2fe424e9c0085”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/version_no_pic.png”,

“aef80885188e9d763282735e53b25c0e”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/version_pc.png”,

“42f3cc914eab7be4258fac3a4889d41d”],

[“https://static.ws.126.net/163/wap/f2e/milk_index/version_standard.png”,

“573408fa002e58c347041e9f41a5cd0d”]

];

var cacheName = ‘sw-precache-v3-new-wap-index-’ + (self.registration ? self.registration.scope : ‘’);

var ignoreUrlParametersMatching = [/^utm_/];

var addDirectoryIndex = function(originalUrl, index) {

var url = new URL(originalUrl);

if (url.pathname.slice(-1) === ‘/’) {

url.pathname += index;

}

return url.toString();

};

var cleanResponse = function(originalResponse) {

// If this is not a redirected response, then we don’t have to do anything.

if (!originalResponse.redirected) {

return Promise.resolve(originalResponse);

}

// Firefox 50 and below doesn’t support the Response.body stream, so we may

// need to read the entire body to memory as a Blob.

var bodyPromise = ‘body’ in originalResponse ?

Promise.resolve(originalResponse.body) :

originalResponse.blob();

return bodyPromise.then(function(body) {

// new Response() is happy when passed either a stream or a Blob.

return new Response(body, {

headers: originalResponse.headers,

status: originalResponse.status,

statusText: originalResponse.statusText

});

});

};

var createCacheKey = function(originalUrl, paramName, paramValue,

dontCacheBustUrlsMatching) {

// Create a new URL object to avoid modifying originalUrl.

var url = new URL(originalUrl);

// If dontCacheBustUrlsMatching is not set, or if we don’t have a match,

// then add in the extra cache-busting URL parameter.

if (!dontCacheBustUrlsMatching ||

!(url.pathname.match(dontCacheBustUrlsMatching))) {

url.search += (url.search ? ‘&’ : ‘’) +

encodeURIComponent(paramName) + ‘=’ + encodeURIComponent(paramValue);

}

return url.toString();

};

var isPathWhitelisted = function(whitelist, absoluteUrlString) {

// If the whitelist is empty, then consider all URLs to be whitelisted.

if (whitelist.length === 0) {

return true;

}

// Otherwise compare each path regex to the path of the URL passed in.

var path = (new URL(absoluteUrlString)).pathname;

return whitelist.some(function(whitelistedPathRegex) {

return path.match(whitelistedPathRegex);

});

};

var stripIgnoredUrlParameters = function(originalUrl,

ignoreUrlParametersMatching) {

var url = new URL(originalUrl);

// Remove the hash; see https://github.com/GoogleChrome/sw-precache/issues/290

url.hash = ‘’;

url.search = url.search.slice(1) // Exclude initial ‘?’

.split(‘&’) // Split into an array of ‘key=value’ strings

.map(function(kv) {

return kv.split(‘=’); // Split each ‘key=value’ string into a [key, value] array

})

.filter(function(kv) {

return ignoreUrlParametersMatching.every(function(ignoredRegex) {

return !ignoredRegex.test(kv[0]); // Return true iff the key doesn’t match any of the regexes.

});

})

.map(function(kv) {

return kv.join(‘=’); // Join each [key, value] array into a ‘key=value’ string

})

.join(‘&’); // Join the array of ‘key=value’ strings into a string with ‘&’ in between each

return url.toString();

};

var hashParamName = ‘_sw-precache’;

//定义需要缓存的url列表

var urlsToCacheKeys = new Map(

precacheConfig.map(function(item) {

var relativeUrl = item[0];

var hash = item[1];

var absoluteUrl = new URL(relativeUrl, self.location);

var cacheKey = createCacheKey(absoluteUrl, hashParamName, hash, false);

return [absoluteUrl.toString(), cacheKey];

})

);

//把cache中的url提取出来,进行去重操作

function setOfCachedUrls(cache) {

return cache.keys().then(function(requests) {

//提取url

return requests.map(function(request) {

return request.url;

});

}).then(function(urls) {

//去重

return new Set(urls);

});

}

//sw安装阶段

self.addEventListener(‘install’, function(event) {

event.waitUntil(

//首先尝试取出存在客户端cache中的数据

caches.open(cacheName).then(function(cache) {

return setOfCachedUrls(cache).then(function(cachedUrls) {

return Promise.all(

Array.from(urlsToCacheKeys.values()).map(function(cacheKey) {

//如果需要缓存的url不在当前cache中,则添加到cache

if (!cachedUrls.has(cacheKey)) {

//设置same-origin是为了兼容旧版本safari中其默认值不为same-origin,

//只有当URL与响应脚本同源才发送 cookies、 HTTP Basic authentication 等验证信息

var request = new Request(cacheKey, {credentials: ‘same-origin’});

return fetch(request).then(function(response) {

//通过fetch api请求资源

if (!response.ok) {

throw new Error('Request for ’ + cacheKey + ’ returned a ’ +

'response with status ’ + response.status);

}

return cleanResponse(response).then(function(responseToCache) {

//并设置到当前cache中

return cache.put(cacheKey, responseToCache);

});

});

}

})

);

});

}).then(function() {

//强制跳过等待阶段,进入激活阶段

return self.skipWaiting();

})

);

});

self.addEventListener(‘activate’, function(event) {

//清除cache中原来老的一批相同key的数据

var setOfExpectedUrls = new Set(urlsToCacheKeys.values());

event.waitUntil(

caches.open(cacheName).then(function(cache) {

return cache.keys().then(function(existingRequests) {

return Promise.all(

existingRequests.map(function(existingRequest) {

if (!setOfExpectedUrls.has(existingRequest.url)) {

//cache中删除指定对象

return cache.delete(existingRequest);

}

})

);

});

}).then(function() {

//self相当于webworker线程的当前作用域

//当一个 service worker 被初始注册时,页面在下次加载之前不会使用它。claim() 方法会立即控制这些页面

//从而更新客户端上的serviceworker

return self.clients.claim();

})

);

});

self.addEventListener(‘fetch’, function(event) {

if (event.request.method === ‘GET’) {

// 标识位,用来判断是否需要缓存

var shouldRespond;

// 对url进行一些处理,移除一些不必要的参数

var url = stripIgnoredUrlParameters(event.request.url, ignoreUrlParametersMatching);

// 如果该url不是我们想要缓存的url,置为false

shouldRespond = urlsToCacheKeys.has(url);

// 如果shouldRespond未false,再次验证

var directoryIndex = ‘index.html’;

if (!shouldRespond && directoryIndex) {

url = addDirectoryIndex(url, directoryIndex);

shouldRespond = urlsToCacheKeys.has(url);

}

// 再次验证,判断其是否是一个navigation类型的请求

var navigateFallback = ‘’;

if (!shouldRespond &&

navigateFallback &&

(event.request.mode === ‘navigate’) &&

isPathWhitelisted([], event.request.url)) {

url = new URL(navigateFallback, self.location).toString();

shouldRespond = urlsToCacheKeys.has(url);

}

// 如果标识位为true

if (shouldRespond) {

event.respondWith(

caches.open(cacheName).then(function(cache) {

//去缓存cache中找对应的url的值

return cache.match(urlsToCacheKeys.get(url)).then(function(response) {

//如果找到了,就返回value

if (response) {

return response;

}

throw Error(‘The cached response that was expected is missing.’);

});

}).catch(function(e) {

// 如果没找到则请求该资源

console.warn(‘Couldn’t serve response for “%s” from cache: %O’, event.request.url, e);

return fetch(event.request);

})

);

}

}

});

这里的策略大概就是优先在cache中寻找资源,如果找不到再请求资源。可以看出,为了实现一个较为简单的缓存,还是比较复杂和繁琐的,所以很多工具就应运而生了。

Workbox


由于直接写原生的sw.js,比较繁琐和复杂,所以一些工具就出现了,而workbox是其中的佼佼者,由google团队推出。

简介

在 Workbox 之前,GoogleChrome 团队较早时间推出过 sw-precache 和 sw-toolbox 库,但是在 GoogleChrome 工程师们看来,workbox 才是真正能方便统一的处理离线能力的更完美的方案,所以停止了对 sw-precache 和 sw-toolbox 的维护。

使用者

有很多团队也是启用该工具来实现serviceworker的缓存,比如说:

淘宝首页

网易新闻wap文章页

百度的Lavas

基本配置

首先,需要在项目的sw.js文件中,引入workbox的官方js,这里用了我们自己的静态资源:

importScripts(

“https://edu-cms.nosdn.127.net/topics/js/workbox_9cc4c3d662a4266fe6691d0d5d83f4dc.js”

);

其中importScripts是webworker中加载js的方式。

引入workbox后,全局会挂载一个workbox对象

if (workbox) {

console.log(‘workbox加载成功’);

} else {

console.log(‘workbox加载失败’);

}

然后需要在使用其他的api前,提前使用配置

//关闭控制台中的输出

workbox.setConfig({ debug: false });

也可以统一指定存储时cache的名称:

//设置缓存cachestorage的名称

workbox.core.setCacheNameDetails({

prefix:‘edu-cms’,

suffix:‘v1’

});

precache

workbox的缓存分为两种,一种的precache,一种的runtimecache。

precache对应的是在installing阶段进行读取缓存的操作。它让开发人员可以确定缓存文件的时间和长度,以及在不进入网络的情况下将其提供给浏览器,这意味着它可以用于创建Web离线工作的应用。

工作原理

首次加载Web应用程序时,workbox会下载指定的资源,并存储具体内容和相关修订的信息在indexedDB中。

当资源内容和sw.js更新后,workbox会去比对资源,然后将新的资源存入cache,并修改indexedDB中的版本信息。

我们举一个例子:

workbox.precaching.precacheAndRoute([

‘./main.css’

]);

indexedDB中会保存其相关信息

这个时候我们把main.css的内容改变后,再刷新页面,会发现除非强制刷新,否则workbox还是会读取cache中存在的老的main.css内容。

即使我们把main.css从服务器上删除,也不会对页面造成影响。

所以这种方式的缓存都需要配置一个版本号。在修改sw.js时,对应的版本也需要变更。

使用实践

当然了,一般我们的一些不经常变的资源,都会使用cdn,所以这里自然就需要支持域外资源了,配置方式如下:

var fileList = [

{

url:‘https://edu-cms.nosdn.127.net/topics/js/cms_specialWebCommon_js_f26c710bd7cd055a64b67456192ed32a.js’

},

{

url:‘https://static.ws.126.net/163/frontend/share/css/article.207ac19ad70fd0e54d4a.css’

}

];

//precache 适用于支持跨域的cdn和域内静态资源

workbox.precaching.suppressWarnings();

workbox.precaching.precacheAndRoute(fileList, {

“ignoreUrlParametersMatching”: [/./]

});

这里需要对应的资源配置跨域允许头,否则是不能正常加载的。且文件都要以版本文件名的方式,来确保修改后cache和indexDB会得到更新。

理解了原理和实践后,说明这种方式适合于上线后就不会经常变动的静态资源。

runtimecache

运行时缓存是在install之后,activated和fetch阶段做的事情。

既然在fetch阶段发送,那么runtimecache 往往应对着各种类型的资源,对于不同类型的资源往往也有不同的缓存策略。

缓存策略

workbox提供的缓存策划有以下几种,通过不同的配置可以针对自己的业务达到不同的效果:

staleWhileRevalidate

这种策略的意思是当请求的路由有对应的 Cache 缓存结果就直接返回,在返回 Cache 缓存结果的同时会在后台发起网络请求拿到请求结果并更新 Cache 缓存,如果本来就没有 Cache 缓存的话,直接就发起网络请求并返回结果,这对用户来说是一种非常安全的策略,能保证用户最快速的拿到请求的结果。

但是也有一定的缺点,就是还是会有网络请求占用了用户的网络带宽。可以像如下的方式使用 State While Revalidate 策略:

workbox.routing.registerRoute(

new RegExp(‘https://edu-cms.nosdn.127.net/topics/’),

workbox.strategies.staleWhileRevalidate({

//cache名称

cacheName: ‘lf-sw:static’,

plugins: [

new workbox.expiration.Plugin({

//cache最大数量

maxEntries: 30

})

]

})

);

networkFirst

这种策略就是当请求路由是被匹配的,就采用网络优先的策略,也就是优先尝试拿到网络请求的返回结果,如果拿到网络请求的结果,就将结果返回给客户端并且写入 Cache 缓存。

如果网络请求失败,那最后被缓存的 Cache 缓存结果就会被返回到客户端,这种策略一般适用于返回结果不太固定或对实时性有要求的请求,为网络请求失败进行兜底。可以像如下方式使用 Network First 策略:

//自定义要缓存的html列表

var cacheList = [

‘/Hexo/public/demo/PWADemo/workbox/index.html’

];

workbox.routing.registerRoute(

//自定义过滤方法

function(event) {

// 需要缓存的HTML路径列表

if (event.url.host === ‘localhost:63342’) {

if (~cacheList.indexOf(event.url.pathname)) return true;

else return false;

} else {

return false;

}

},

workbox.strategies.networkFirst({

cacheName: ‘lf-sw:html’,

plugins: [

new workbox.expiration.Plugin({

maxEntries: 10

})

]

})

);

cacheFirst

这个策略的意思就是当匹配到请求之后直接从 Cache 缓存中取得结果,如果 Cache 缓存中没有结果,那就会发起网络请求,拿到网络请求结果并将结果更新至 Cache 缓存,并将结果返回给客户端。这种策略比较适合结果不怎么变动且对实时性要求不高的请求。可以像如下方式使用 Cache First 策略:

workbox.routing.registerRoute(

new RegExp(‘https://edu-image.nosdn.127.net/’),

workbox.strategies.cacheFirst({

cacheName: ‘lf-sw:img’,

plugins: [

//如果要拿到域外的资源,必须配置

//因为跨域使用fetch配置了

//mode: ‘no-cors’,所以status返回值为0,故而需要兼容

new workbox.cacheableResponse.Plugin({

statuses: [0, 200]

}),

new workbox.expiration.Plugin({

maxEntries: 40,

//缓存的时间

maxAgeSeconds: 12 * 60 * 60

})

]

})

);

networkOnly

比较直接的策略,直接强制使用正常的网络请求,并将结果返回给客户端,这种策略比较适合对实时性要求非常高的请求。

cacheOnly

这个策略也比较直接,直接使用 Cache 缓存的结果,并将结果返回给客户端,这种策略比较适合一上线就不会变的静态资源请求。

举个栗子

又到了举个栗子的阶段了,这次我们用淘宝好了,看看他们是如何通过workbox来配置serviceworker的:

//首先是异常处理

self.addEventListener(‘error’, function(e) {

self.clients.matchAll()

.then(function (clients) {

if (clients && clients.length) {

clients[0].postMessage({

type: ‘ERROR’,

msg: e.message || null,

stack: e.error ? e.error.stack : null

});

}

});

});

self.addEventListener(‘unhandledrejection’, function(e) {

self.clients.matchAll()

.then(function (clients) {

if (clients && clients.length) {

clients[0].postMessage({

type: ‘REJECTION’,

msg: e.reason ? e.reason.message : null,

stack: e.reason ? e.reason.stack : null

});

}

});

})

//然后引入workbox

importScripts(‘https://g.alicdn.com/kg/workbox/3.3.0/workbox-sw.js’);

workbox.setConfig({

debug: false,

modulePathPrefix: ‘https://g.alicdn.com/kg/workbox/3.3.0/’

});

//直接激活跳过等待阶段

workbox.skipWaiting();

workbox.clientsClaim();

//定义要缓存的html

var cacheList = [

‘/’,

‘/tbhome/home-2017’,

‘/tbhome/page/market-list’

];

//html采用networkFirst策略,支持离线也能大体访问

workbox.routing.registerRoute(

function(event) {

// 需要缓存的HTML路径列表

if (event.url.host === ‘www.taobao.com’) {

if (~cacheList.indexOf(event.url.pathname)) return true;

else return false;

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

前端面试题汇总


前端面试题是我面试过程中遇到的面试题,每一次面试后我都会复盘总结。我做了一个整理,并且在技术博客找到了专业的解答,大家可以参考下:

由于篇幅有限,只能分享部分面试题,完整版面试题及答案可以【点击我】阅读下载哦~

感悟

‘www.taobao.com’) {

if (~cacheList.indexOf(event.url.pathname)) return true;

else return false;

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-c5NzCapz-1712475373918)]

[外链图片转存中…(img-72oquMkH-1712475373919)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

[外链图片转存中…(img-Ebztu1at-1712475373919)]

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

前端面试题汇总


前端面试题是我面试过程中遇到的面试题,每一次面试后我都会复盘总结。我做了一个整理,并且在技术博客找到了专业的解答,大家可以参考下:

由于篇幅有限,只能分享部分面试题,完整版面试题及答案可以【点击我】阅读下载哦~

感悟

春招面试的后期,运气和实力都很重要,自己也是运气比较好,为了回馈粉丝朋友们(毕竟自己也玩了这么久哈哈哈),整理个人感悟和总结以上。最后祝愿大家能够收获理想offer!!

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值