背景 最近在查看新技术和文章时, 突然看到一个很有意思的新特性, 研究之后以后发现我们也可以使用上, 用于优化首屏加载时间
原理 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
内, 因此这次优化的效果非常好, 而且这个方法非常通用, 其他类似系统有登录/权限请求的也可以同样使用这个方案