背景 最近在查看新技术和文章时, 突然看到一个很有意思的新特性, 研究之后以后发现我们也可以使用上, 用于优化首屏加载时间
原理 link元素的rel属性值在设置成preload的时候, 可以实现预先请求这个地址, 后续在真实请求的时候, 就能直接拿到这份结果来使用, 不用再次请求
同时, 可以通过as属性来指定请求的类型是fetch, 就可以实现提前发起http GET请求(POST不行), 获取一些信息. 在我们系统中就是两个的接口: 用户登录相关信息和项目权限信息
实现过程 preload按官方api用法, 直接将请求路径设置到href内
1 <link  rel ="preload"  href ="/api/v1/user/info"  as ="fetch"  /> 
 
加完之后测试, 发现它没有带上cookie, 无法获取登录态, 需要额外设置crossorigin="use-credentials"
1 <link  rel ="preload"  href ="/api/v1/user/info"  as ="fetch"  crossorigin ="use-credentials"  /> 
 
这时在network里就能看到页面加载最初就发起了一个请求了, 返回值也是正确的, 但是在页面加载完成之后却还是又发起了一次请求, 同时控制台提示了一个warning
1 The resource https://xxxxxxxxx/api/v1/user/info was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. 
 
这样明显就是虽然link设置了preload, 但是返回的结果没有被用上, 又按照官方文档开了一个新页面, 在控制台尝试一下发现是没问题的, 说明是我们系统内的使用方法的问题. 排查了参数/路径等没问题之后, 注意到浏览器内network日志记录里, 我们的请求类型是xhr, 怀疑是不是这一块的问题. 尝试将系统内的请求直接使用fetch写死, 发现是可以复用preload的, 于是定位到了这个问题
fetch最初我们系统使用过fetch作为基础请求库, 后来发现兼容性不够好, 部分电脑的chrome版本过低不支持, 需要手动升级才有fetch, 于是将底层改为使用axios实现了, 而axios是使用XMLHttpRequest来实现网络请求的, 所以不支持link-preload
解决起来也比较简单, 旧的其实已经封装过fetch方法, 将旧的fetch加上自动兼容就可以直接使用了
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 const  _fetch: <T>(url: string , options?: AxiosRequestConfig ) =>  Promise <AxiosResponse<T>> = async  <T>(   url: string ,   options: AxiosRequestConfig = {},  ) =>  {  if  (!window .fetch) {     return  await  _axios(url, options);   }   const  log: fetchLog<T> = {     url,     options,     startTime : new  Date (),   };   const  requestPerformance = webPerformance.startRequest(url, options.method as  any );   try  {     const  response = await  fetch(url, { ...options, credentials : 'include'  });     log.response = response as  unknown as  AxiosResponse;     try  {       _.set(response, 'result' , await  response.clone().json());     } catch  {       _.set(response, 'result' , await  response.text());     }     log.response.data = _.get(response, 'result' );     return  log.response;   } catch  (error) {     log.error = error;     error.error_id = md5(Date .now() + navigator.userAgent + Math .random());     const  response = (log.response = error.response || {});     const  result = _.get(response.data, 'msg' , response.data);     if  (response.status === 401 ) {       log.error = new  FetchError.AuthError(result, response);     }     if  (response.status === 404 ) {       log.error = new  FetchError.NotFoundError(result, response);     }     if  (response.status === 500 ) {       log.error = new  FetchError.InternalServerError(result, response);     }     if  (response.status >= 400 ) {       log.error = new  FetchError.NetworkError(result, response);     }     throw  log.error;   } finally  {     if  (log.error) {       _.attempt(() =>          captureException(log.error, {           error_source : 'request error' ,           referer : window .location.href,           info : url,           trace_id : log.response.headers?.['trace-id' ],           conditions : [`method.${options.method || 'GET' } ` , `status.${log.response.status} ` , `error_id.${_.get(log.error, 'error_id' , '' )} ` ],         }),       );     }     _.attempt(() =>  webPerformance.sendClientLog(requestPerformance(log.response.status, log.response.headers['trace-id' ])));     log.endTime = new  Date ();     log.cost = log.endTime.getTime() - log.startTime.getTime();     if  (log.error) {       console .log('fetch log: ' , log);     } else  {       debug.log('fetch log: ' , log);     }   } }; 
 
代码里使用 1 UTILS.restful.fetch<{ code : number ; msg: string ; data: AuthUserInfo }>(api.auth.getUserInfo()); 
 
自动注入路径 这样改造完之后, 就可以直接使用link-preload预加载请求了, 但是这里的请求路径是写死在public/index.html中的, 而且我们实际上还有请求参数的, 如果固定写死不好维护, 使用htmlWebpackPlugin实现一下自动注入
1 <link  rel ="preload"  href ="<%= htmlWebpackPlugin.options.data.userInfoPreload %>"  as ="fetch"  crossorigin ="use-credentials"  /> 
 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 new  HtmlWebpackPlugin(  Object .assign(     {},     {       inject : true ,       template : paths.appHtml,       data : {         userInfoPreload : `${api.auth.getUserInfo()} ?${queryString.stringify({ apps: buildConfig.includesAuthApps }, { arrayFormat: 'index'  })} ` ,         permissionPreloadUrl : `${api.auth.getPermission()} ` ,       },     },        ), ); 
 
脚本加载 第二个权限相关请求会复杂一点, 请求权限的时候要区分当前是哪个应用以及项目, 在每次切换时都会重新请求的. 但是为了首屏时间, 也可以通过script来实现
在public/index.html中加上自动解析url中的app和project, 并直接加上preload-link对permission的请求即可, 因为这里有部分是使用localStorage的逻辑, 所以不能全部放在webpack.config.js中处理, 只能在页面加载时候处理了. 保证和真实使用时统一逻辑就行.
  
    即使preload路径修改或者参数错误, 也不会影响到页面的正常使用, 因为预加载是如果一样就使用, 如果不一样, 会直接发起新的请求的, 保证了正式使用时的准确性
  
 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <script >   var  appAuthConfig = <%= htmlWebpackPlugin.options.data.appAuthConfig %>;   var  paths = window .location.pathname.split('/' );   var  app = paths[1 ];   var  project = paths[2 ];   var  authApp = appAuthConfig[app] || 'all' ;   if  (authApp !== 'all'  && !project) {     var  storeConfig = JSON .parse(localStorage .getItem('STORE_APP_CONFIG' ) || '{}' );     if  (storeConfig && storeConfig._all_last && storeConfig._all_last.project) {       project = storeConfig._all_last.project;     }   } else  if  (authApp === 'all' ) {     project = 'all' ;   }   var  preloadLink = document .createElement('link' );   preloadLink.href = '<%= htmlWebpackPlugin.options.data.permissionPreloadUrl %>?app='  + authApp + '&project='  + project;   preloadLink.rel = 'preload' ;   preloadLink.as = 'fetch' ;   preloadLink.crossOrigin = 'use-credentials' ;   document .head.appendChild(preloadLink); </script > 
 
小结 这次的优化逻辑非常简单, 但是效果却非常好, 因为是将两个必须请求和会强制阻塞页面加载的逻辑提前到了页面最初加载时. 在现在系统内, html和js文件加载一般在300-500ms内, 而这两个请求加起来可能占用400ms, 优化前页面loading完成到能正常使用的时间大概需要800ms, 修改完成之后, 首屏时间是js文件与user请求时长中最长的, 一般在400ms内, 因此这次优化的效果非常好, 而且这个方法非常通用, 其他类似系统有登录/权限请求的也可以同样使用这个方案