iOS离线包方案调研

iOS离线包方案调研

为啥使用离线包

离线包相对于混淆来说是一种更稳定的过审方案, 其主要优势如下

传统的 H5 技术容易受到网络环境影响,因而降低 H5 页面的性能。通过使用离线包,可以解决该问题,同时保留 H5 的优点。
离线包 是将包括 HTML、JavaScript、CSS 等页面内静态资源打包到一个压缩包内。预先下载该离线包到本地,然后通过客户端打开,直接从本地加载离线包,从而最大程度地摆脱网络环境对 H5 页面的影响。

实现动态更新:在推出新版本或是紧急发布的时候,可以把修改的资源放入离线包,通过更新配置让应用自动下载更新。因此, 无需通过应用商店审核,就能让用户及早接收更新

离线包的方案选择

目前主流的离线包的请求拦截方案有两种:

  • 通过NSURLProtocol实现, 注册scheme拦截
  • WKURLSchemeHandler实现, 自定义sheme拦截

WKURLSchemeHandler

WKURLSchemeHandler 是 WebKit 框架中的一个类,用于处理自定义的 URL 协议。WebKit 是一个提供网页渲染和浏览功能的框架,它主要用于创建浏览器和网页视图等, 其中WKURLSchemeHandler是iOS11之后的API, 其大致实现如下.

ini 复制代码
​
  WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
  //其中CustomSchemeHandler需要实现WKURLSchemeHandler协议
  CustomSchemeHandler *handler = [[CustomSchemeHandler alloc] init];
  NSString *scheme = "yourscheme";
  NSString *schemes = "yourschemes"
  //http + https
  [configuration setURLSchemeHandler:handler forURLScheme:scheme];
  [configuration setURLSchemeHandler:handler forURLScheme:schemes];
  _webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:configuration];
​
...
  
  //请求时只要scheme一致, 就能被CustomSchemeHandler拦截
  NSString *requestURL = @"yourscheme://{resourcePath}"
  NSURLRequest *request = [NSURLRequest requestWithURL:requestURL];
  [self.webView loadRequest:request];

用户需要自定义scheme,访问时域名大概customScheme://{packageId}/page,需要自定义scheme, 可以针对单个的网页进行拦截,粒度较细.

NSURLProtocol

NSURLProtocol 是 Foundation 框架中的一个抽象类,它提供了一个基本的框架来实现自定义的 URL 协议。通过继承 NSURLProtocol 类,你可以定义自己的 URL 协议,并在应用程序中使用该协议来进行网络请求

而mpaas中使用的就是这种方案,相对于WKURLSchemeHandler可以提供虚拟域名的支持,mpass的文档说明如下:

总体上来说2种方案实现思路是一致的,API的相似度很高,但是在前端处理的细节上会有些区别 ,下面我仿照mpaas的方式,实现简单的离线包

NSURLPotocol对象注册

WKWebView并没有提供公开注册NSURLProtocol的方法,但是根据Apple的WebKit开源项目中的测试代码,可以得知使用私有api完成这已功能

ini 复制代码
  Class cls = NSClassFromString(@"WKBrowsingContextController");
  SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
  [(id)cls performSelector:sel withObject:@"http"];
  [(id)cls performSelector:sel withObject:@"https"];

其中WKBrowsingContextController和registerSchemeForCustomProtocol都是私有的,上线时需要进行混淆.

这样WKWebView中所有的http/https的请求都会被注册的NSURLPotocol子类对象所拦截,比如

objectivec 复制代码
//OfflinePackageURLProtocol负责离线资源的加载
[NSURLProtocol registerClass:[OfflinePackageURLProtocol class]];
//KKJSBridgeAjaxURLProtocol负责重新组装来自ajax的请求
[NSURLProtocol registerClass:[KKJSBridgeAjaxURLProtocol class]];

其中OfflinePackageURLProtocol,和KKJSBridgeAjaxURLProtocol都需要继承NSURLProtocol. 其核心方法如下

objectivec 复制代码
// 该方法用于判断指定的网络请求是否可以由自定义的 URL 协议处理。如果该方法返回 YES,则表示该请求可以由该协议处理;如果返回 NO,则表示该请求不能由该协议处理
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
// 该方法用于启动网络请求。在该方法中,可以实现自定义的网络传输逻辑,来处理网络请求并返回响应。
- (void)startLoading;
// 该方法用于停止网络请求。在该方法中,可以实现清理逻辑,来释放与请求相关的资源。
- (void)stopLoading;

NSURLProtocol可以注册多个子类,并且拦截的顺序是后注册的先拦截 , 被拦截的请求,会重新由新创建的Session来管理,因此不会继续被后续的Protocol处理. 所以KKJSBridgeAjaxURLProtocol需要拦截ajaxhook之后的请求重新组装body, 它的注册必须在OfflinePackageURLProtocol之后

此外, 使用NSURLProtocol 的registerClass方法会污染[NSURLSession sharedSession]对象. 通常向NSURLSession中注册NSURLProtocol的方法如下

ini 复制代码
// 创建一个默认的 session configuration 对象
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
​
// 为 session configuration 对象添加自定义的 NSURLProtocol 子类
config.protocolClasses = @[[MyURLProtocol class]];
​
// 创建 NSURLSession 对象
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];

使用NSURLProtocol 的registerClass会自动完成这一过程,从而导致所有的[NSURLSession sharedSession]管理的请求都会被拦截, 除了效率问题以外,还会可能会导致回调失效(因为通常在拦截中会构建新的Session和Task对象)等问题, 因此如果是Native的请求, 我们需要避免使用[NSURLSession sharedSession]以免造成不必要的麻烦.

离线资源的加载的实现

离线包解压之后的沙盒目录如下

其中zipFiles中为打包的离线包资源, 可以通过内嵌或者下载的方式,保存到沙盒中,unzipFiles为解压之后的目录.其文件夹名即为packageId/version, 这样对于同一个离线包来说可以通过版本来进行升级或者版本回退. 其中static中有一些不变动的资源后续可以单独拿出来,作为一个资源包内嵌或者下发到app中减少网路流量.

ini 复制代码
 //仿照Mpaas的api, 离线包控制器通过packageId初始化
 OfflinePackageController *vc = [OfflinePackageController controllerWithPackageId:@"6"];
 [self.navigationController pushViewController:vc animated:YES];
​
// packageId映射为url再通过URLProtocol拦截
- (instancetype)initWithPackageId:(NSString *)packageId{
    if (self = [super init]) {
        _url = [NSString stringWithFormat:@"https://%@.package",packageId];
        [self commonInit];
    }
    return self;
}

其中url就是所谓的虚拟域名, 在本例子中,packageId为app, 虚拟域名为https://app.package,这样在访问内部资源时,比如主页路径为https://app.package/index.html

OfflinePackageURLProtocol的核心代码

ini 复制代码
- (void)startLoading{
    NSURLRequest *originRequest = self.request;
    NSMutableURLRequest *mutableReqeust = [originRequest mutableCopy];
​
    // 标示改request已经处理过了,防止无限循环
    [NSURLProtocol setProperty:@YES forKey:kOfflinePackageDidHandleRequest inRequest:mutableReqeust];
    
    if ([self.request.URL.host containsString:@".package"]) {
        //本地
        NSString *packageId = [self.request.URL.host componentsSeparatedByString:@"."][0];
        NSString *relativePath;
        if (self.request.URL.pathExtension.length > 0) {
            relativePath = self.request.URL.relativePath;
        }else{
            if([self.request.URL.lastPathComponent isEqualToString:@"/"]){
                relativePath = @"index.html";
            }else{
                relativePath = [NSString stringWithFormat:@"%@.html",self.request.URL.lastPathComponent];
            }
        }
        //根据离线包id 版本号 来定位离线包资源
        NSString *version = [PackageManager currentVersionOfPackage:packageId];
        NSString *filePath = [@[
            NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0],
            @"unzipFiles",
            packageId,
            version,
            relativePath
        ] componentsJoinedByString:@"/"];
        NSData *data = [NSData dataWithContentsOfFile:filePath];;
        NSURLResponse *res = [[NSURLResponse alloc] initWithURL:self.request.URL MIMEType:[self getMimeTypeWithFilePath:filePath] expectedContentLength:data.length textEncodingName:nil];
        [self.client URLProtocol:self didReceiveResponse:res cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        [self.client URLProtocol:self didLoadData:data];
        [self.client URLProtocolDidFinishLoading:self];
        
    }else{
        //非离线包资源,构造task,继续发起请求.
        NSURLSessionConfiguration *configure = [NSURLSessionConfiguration defaultSessionConfiguration];
        self.session  = [NSURLSession sessionWithConfiguration:configure delegate:self delegateQueue:self.queue];
        self.task = [self.session dataTaskWithRequest:mutableReqeust];
        [self.task resume];
    }

通过host判断是否离线包资源, 如果是离线包资源,构造NSURLResponse,通过client 对象返回.如果不是离线包资源, 重新构造Task并进行请求.

通过上述代码可知, 对于非离线包的资源, 我们应该尽量在canInitWithRequest中提前判断协议不可用,否则如果进入startLoading接管后, 由于NSURLSession和Task对象是重新构造的,会导致一些问题, 比如前端无法获取网络请求的进度.

NSURLProtocol采坑

NSURLProtocol拦截Post请求Body丢失的问题

上述方案存在一个问题, 服务端接受的ajax的post请求body为空,在URLProtocol中断点可以得知, NSURLProtocol拦截Post请求后会将参数清空.

这个问题的产生主要是因为WKWebView的网络请求的进程与APP不是同一个进程,所以网络请求的过程是这样的: 由APP所在的进程发起request,然后通过IPC通信(进程间通信)将请求的相关信息(请求头、请求行、请求体等)传递给webkit网络线进程接收包装,进行数据的HTTP请求,最终再进行IPC的通信回传给APP所在的进程的。这里如果发起的request请求是post请求的话,由于要进行IPC数据传递,传递的请求体body中根据系统调度,将其舍弃,最终在WKWebView网络进程接受的时候请求体body中的内容变成了空,导致此种情况下的服务器获取不到请求体,导致问题的产生。

所以这里的解决思路就是想办法将WebView的Post请求的body传递到native端保存, 然后请求时重新构造参数. 根据这个思路目前有2种解决方案

  • 将body放到请求Header中, 然后重新构造.

    由于Header的长度限制, 这种方案不适合文件传输.

  • 通过JSAPI将参数发送到native端保存.

    这种方案通用性更高, 需要实现body的缓存.

目前采用第二种方案,具体步骤分为2步

  • js中注入ajax hook, 对open和send进行hook.

    • open中生新的成带标记的URL,同时 ,用于native端区分哪些请求是ajax发出的比如http://192.168.33.39:8000/testpost?KKJSBridge-RequestId=166986530337824940,其中KKJSBridge用来标记这个请求是由ajax发出的,RequestId用来区分请求,进行body的匹配.

    • send中负责将根据body类型进行编码, 并将body发送到native端.

      核心代码如下

      ini 复制代码
      var originOpen = XMLHttpRequest.prototype.open;
      XMLHttpRequest.prototype.open = function (method, url, async, username, password) {
          var args = [].slice.call(arguments);
          var xhr = this;
          // 生成唯一请求id
          xhr.requestId = _KKJSBridgeXHR.generateXHRRequestId();
          xhr.requestUrl = url;
          xhr.requestHref = document.location.href;
          xhr.requestMethod = method;
          xhr.requestAsync = async;
          if (_KKJSBridgeXHR.isNonNormalHttpRequest(url, method)) { // 如果是非正常请求,则调用原始 open
              return originOpen.apply(xhr, args);
          }
          if (!window.KKJSBridgeConfig.ajaxHook) { // 如果没有开启 ajax hook,则调用原始 open
              return originOpen.apply(xhr, args);
          }
          // 生成新的 url
          args[1] = _KKJSBridgeXHR.generateNewUrlWithRequestId(url, xhr.requestId);
          originOpen.apply(xhr, args);
      };
      var originSend = XMLHttpRequest.prototype.send;
      XMLHttpRequest.prototype.send = function (body) {
          var args = [].slice.call(arguments);
          var xhr = this;
          var request = {
              requestId: xhr.requestId,
              requestHref: xhr.requestHref,
              requestUrl: xhr.requestUrl,
              bodyType: "String",
              value: null
          };
          if (_KKJSBridgeXHR.isNonNormalHttpRequest(xhr.requestUrl, xhr.requestMethod)) { // 如果是非正常请求,则调用原始 send
              return originSend.apply(xhr, args);
          }
          if (!window.KKJSBridgeConfig.ajaxHook) { // 如果没有开启 ajax hook,则调用原始 send
              return originSend.apply(xhr, args);
          }
          if (!body) { // 没有 body,调用原始 send
              return originSend.apply(xhr, args);
          }
          else if (body instanceof ArrayBuffer) { // 说明是 ArrayBuffer,转成 base64
              request.bodyType = "ArrayBuffer";
              request.value = KKJSBridgeUtil.convertArrayBufferToBase64(body);
          }
          else if (body instanceof Blob) { // 说明是 Blob,转成 base64
              request.bodyType = "Blob";
              var fileReader = new FileReader();
              fileReader.onload = function (ev) {
                  var base64 = ev.target.result;
                  request.value = base64;
                  _KKJSBridgeXHR.sendBodyToNativeForCache("AJAX", xhr, originSend, args, request);
              };
              fileReader.readAsDataURL(body);
              return;
          }
          else if (body instanceof FormData) { // 说明是表单
              request.bodyType = "FormData";
              request.formEnctype = "multipart/form-data";
              KKJSBridgeUtil.convertFormDataToJson(body, function (json) {
                  request.value = json;
                  _KKJSBridgeXHR.sendBodyToNativeForCache("AJAX", xhr, originSend, args, request);
              });
              return;
          }
          else { // 说明是字符串或者json
              request.bodyType = "String";
              request.value = body;
          }
          // 发送到 native 缓存起来
          _KKJSBridgeXHR.sendBodyToNativeForCache("AJAX", xhr, originSend, args, request, xhr.requestAsync);
      };
  • native端构造URLProtocol,对ajax发出的请求进行拦截,解析得到RequestId, 根据RequestId获取并组装body.大致逻辑如下

    ini 复制代码
    - (void)startLoading {
        NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
        NSString *requestId;
        if ([mutableReqeust.URL.absoluteString containsString:kRequestId]) {
            requestId = [self getRequestId:mutableReqeust.URL.absoluteString];
        }
        
        self.requestId = requestId;
        self.requestHTTPMethod = mutableReqeust.HTTPMethod;
        
        NSArray *bodySupportMethods = @[@"POST",@"PUT"];
        
        if (mutableReqeust.HTTPMethod.length > 0 && [bodySupportMethods containsObject:mutableReqeust.HTTPMethod]) {
            NSDictionary *body = [self getBodyFromRequestId:requestId];
            if (body) {
                // 从把缓存的 body 设置给 request
                [self setBody:bodyReqeust forRequest:mutableReqeust];
            }
        }
        NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:nil];
        self.customTask = [session dataTaskWithRequest:mutableReqeust];
        [self.customTask resume];
    }
    ​

Cookie同步问题

Cookie的分类

WKWebView的Cookie管理一直是比较令人头疼的问题.这是因为客户端,服务端和前端都能进行Cookie的操作,其中Session Cookie是不需要持久化的, 由WKWebView对应的WKProcessPool进行管理,对于需要持久化的Cookie来说,不同端操作的Cookie对象是分开保存的,如下图

iOS 沙盒目录/Library/Cookies中存储的2种Cookie, 其中 {appid}.binarycookies是NSHTTPCookieStorage的文件对象, Cookies.binarycookies是WKWebView的Cookie对象,通过WKHTTPCookieStore进行管理.

WKHTTPCookieStore和NSHTTPCookieStorage是iOS中两个用于管理Cookie的类。它们都提供了类似的功能,如存储、删除、更新和查找Cookie,但它们在实现方式和使用场景上有所不同。

  • WKHTTPCookieStore是WebKit框架中提供的类,主要用于管理WebView的Cookie。它提供了一系列方法,可以在WebView加载请求时,自动添加、删除或修改Cookie,以便在WebView中保持用户的登录状态和个性化设置。WKHTTPCookieStore的使用方式和WebView相关,只能在WebView的代理方法或JavaScript脚本中调用,不能在其他地方使用。具体来说, 由前端发起的网络请求,通过服务端Set-Cookie产生的Cookie 和 JavaScript中通过document.cookie设置的Cookie 由 WKHTTPCookieStore 进行管理, 由前端发起的网络请求, 会携带WKHTTPCookieStore中管理的持久化Cookie 和 WKProcessPool管理的Session Cookie.
  • NSHTTPCookieStorage是Foundation框架中提供的类,主要用于管理网络请求的Cookie。它提供了一系列静态方法,可以在发送网络请求时,自动添加、删除或修改Cookie,以便在网络传输中保持用户的登录状态和个性化设置。NSHTTPCookieStorage的使用方式和网络请求相关,只能在NSURLSession、AFNetworking等网络库的方法中调用,不能在其他地方使用。具体来说: 除了客户端通过NSHTTPCookieStorage进行的Cookie操作以外, 由客户端发起的网络请求,通过服务端Set-Cookie产生的Cookie也交由NSHTTPCookieStorage管理, 由客户端发起的网络请求,会携带NSHTTPCookieStorage中管理的Cookie

在使用离线包时, 由于我们使用了URLProtocol进行拦截, 相当于将前端的请求转发到客户端进行处理,为了避免Cookie使用的混乱,我们要统一使用NSHTTPCookieStorage来进行Cookie的管理, 所有的前端Cookie需要同步到客户端中, 同时前端发起的请求需要从NSHTTPCookieStorage同步Cookie

需要处理的场景
  • WKWebView的cookie同步到NSHTTPCookieStorage, 这里有3种情况

    • 场景1: 跳转,包括webview LoadRequest, 前端a标签, 服务端的重定向 都看做是跳转, 选择WKNavigationDelegate中 接受响应后,跳转之前的代理方法decidePolicyForNavigationResponse中进行同步

      objectivec 复制代码
      - (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler {
          WKHTTPCookieStore *cookieStroe = webView.configuration.websiteDataStore.httpCookieStore;
          [cookieStroe getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) {
              for (NSHTTPCookie *cookie in cookies) {
                  [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
              }
          }];
          decisionHandler(WKNavigationResponsePolicyAllow);
      };
    • 场景2: Js中由 document.cookie设置, 同过hook cookie的set方法, 将消息转发到native端处理.

      objectivec 复制代码
      //这里需要注意虽然在浏览器环境中可以仅仅通过name,value来创建Cookie 但是在Native端创建Cookie时并无webView作为上下文, 因此必须声明domain和path属性,否则无法创建成功,以下5个字段是必须设置的.
        NSHTTPCookie *cookie = [NSHTTPCookie cookieWithProperties:@{
            NSHTTPCookieName:@"cookie_form_user",
            NSHTTPCookieValue:@"1",
            NSHTTPCookieDomain:[NSString stringWithFormat:@"%@.package",appId],
            NSHTTPCookieExpires:[NSDate dateWithTimeIntervalSinceNow:86400],
            NSHTTPCookiePath: @"/",
        }];
        [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
    • 场景3: 这个情况比较特殊, 如果前端进行的AjaxHook, 将请求转发到客户端代理,这时通过服务端Set-Cookie产生的Cookie将不会进行任何存储.在一些场景下会产生问题,比如 如果用户的登录是通过前端登录的,同时又进行了AjaxHook, 是无法通过Cookie保存信息的 , 这里我们需要手动保存Cookie来避免该场景发生. 因为Ajax请求中服务端Set-Cookie, 该请求会被客户端接管, 可以在客户端请求完成的回调中单独处理, 比如

      objectivec 复制代码
      //客户端的请求回调中处理Server Set-Cookie
      [self.sessionManager dataTaskWithRequest:request uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse * _Nonnull response, id  _Nullable responseObject, NSError * _Nullable error) {
              if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
                  NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
                  NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:[httpResponse allHeaderFields]           forURL:httpResponse.URL];
                  for (NSHTTPCookie *cookie in cookies) {
                      [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
                  }
            ...
       }];
  • WKWebView请求时,NSHTTPCookieStorage中Cookie同步,分为2种情况

    • 场景1: WebView 跳转请求, 在这里选择WKNavigationDelegate中发送请求前的代理方法decidePolicyForNavigationAction中进行同步

      ini 复制代码
      - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
        if ([navigationAction.request isKindOfClass:NSMutableURLRequest.class]) {
          NSMutableURLRequest *request = navigationAction.request
              NSArray<NSHTTPCookie *> *availableCookie = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:request.URL];
            if (availableCookie.count > 0) {
                NSDictionary *reqHeader = [NSHTTPCookie requestHeaderFieldsWithCookies:availableCookie];
                NSString *cookieStr = [reqHeader objectForKey:@"Cookie"];
                [request setValue:cookieStr forHTTPHeaderField:@"Cookie"];
            }
        }
        decisionHandler(WKNavigationActionPolicyAllow);
      }
    • 场景2: WebView中Ajax请求, 在URLProtocol的startLoading中同步Cookie

      ini 复制代码
      - (void)startLoading {
          NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
          NSArray<NSHTTPCookie *> *availableCookie = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:mutableReqeust.URL];
          if (availableCookie.count > 0) {
              NSDictionary *reqHeader = [NSHTTPCookie requestHeaderFieldsWithCookies:availableCookie];
              NSString *cookieStr = [reqHeader objectForKey:@"Cookie"];
              [mutableReqeust setValue:cookieStr forHTTPHeaderField:@"Cookie"];
          }
          ...
          NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:(id<NSURLSessionDelegate>)[KKJSBridgeWeakProxy proxyWithTarget:self] delegateQueue:nil];
          self.customTask = [session dataTaskWithRequest:mutableReqeust];
      }
相关推荐
deli0071 小时前
32K 上下文塞满后,AI 最先忘掉什么?120 轮对话实测
前端
deli0072 小时前
手机拍灯为什么有一条条黑纹?PWM 240Hz 实测
前端
wangruofeng7 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng7 小时前
给前端开发者的 100 个设计参考网站
前端·设计
星栈10 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技10 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户479492835691510 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马12 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng13 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding14 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js