脚本加载优化
为了提高首屏渲染加载页面,避免一些脚本在首屏直接加载
-
降低首屏阻塞资源,减少非首屏必须 JS 的初始化成本
-
第三方脚本,如facebook SDK、飞书登录sdk等等,这些统一按需加载
- 避免在index.html直接引入脚本,会导致所有页面都会加载脚本
- 首屏 JS 负担下降:非登录/非授权页面不再加载 FB/飞书 SDK。
-
统一外部脚本加载方法,支持去重、缓存、失败重试能力
-
第三方脚本加载统一治理,避免多处重复注入和竞态。
外部脚本加载方法
const loadingScripts = new Map<string, Promise<void>>();
type LoadExternalScriptOptions = {
id?: string;
attrs?: Record<string, string>;
};
function getScriptSelector(src: string, id?: string) {
if (id) return `script#${id}`;
return `script[src="${src}"]`;
}
export function loadExternalScript(src: string, options: LoadExternalScriptOptions = {}) {
const { id, attrs } = options;
const cacheKey = id || src;
if (loadingScripts.has(cacheKey)) {
return loadingScripts.get(cacheKey)!;
}
const selector = getScriptSelector(src, id);
const existedScript = document.querySelector<HTMLScriptElement>(selector);
const scriptReadyState = (existedScript as (HTMLScriptElement & { readyState?: string }) | null)
?.readyState;
const isLoadedScript =
existedScript?.dataset.loaded === 'true' ||
scriptReadyState === 'complete' ||
scriptReadyState === 'loaded';
if (isLoadedScript) {
return Promise.resolve();
}
const scriptPromise = new Promise<void>((resolve, reject) => {
const scriptEl = existedScript || document.createElement('script');
scriptEl.src = src;
scriptEl.async = true;
scriptEl.defer = true;
if (id) {
scriptEl.id = id;
}
if (attrs) {
Object.entries(attrs).forEach(([key, value]) => {
scriptEl.setAttribute(key, value);
});
}
scriptEl.onload = () => {
scriptEl.dataset.loaded = 'true';
resolve();
};
scriptEl.onerror = () => {
loadingScripts.delete(cacheKey);
reject(new Error(`load script failed: ${src}`));
};
if (!existedScript) {
document.head.appendChild(scriptEl);
}
});
loadingScripts.set(cacheKey, scriptPromise);
return scriptPromise;
}
- 使用
await loadExternalScript('https://connect.facebook.net/en_US/sdk.js', {
id: 'facebook-sdk',
attrs: {
crossorigin: 'anonymous',
},
});
- 飞书登录脚本 (web扫码)
try {
await loadExternalScript(
'https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js',
{
id: 'feishu-login-sdk',
attrs: {
crossorigin: 'anonymous',
},
},
);
} catch (error) {
console.error('飞书SDK加载失败', error);
return;
}
defer/async使用
defer和async都是异步加载,都能避免脚本阻塞页面解析,但执行时机不同async= 下载完就执行,defer= 先下载,等页面解析完再按顺序执行
async
- 下载脚本时:不阻塞 HTML 解析
- 执行脚本时:一旦下载完成,立刻执行
- 执行顺序:不保证按书写顺序
- 适合:彼此独立的脚本,比如统计、广告、埋点
注意问题:
- 由于
async脚本的执行时机不确定 - 一旦脚本下载完成,浏览器会暂停 HTML 解析,立刻执行这个脚本
- 执行时会可能阻塞HTML解析
- 如果HTML没解析玩,执行时会阻塞HTML解析
defer
- 下载脚本时:不阻塞 HTML 解析
- 执行脚本时:等 HTML 全部解析完成后 再执行
- 执行顺序:保证按书写顺序
- 适合:依赖 DOM、多个脚本有先后关系的业务代码
async 的场景
- 埋点统计
- 广告脚本
- 在线客服
- 和页面主逻辑无依赖的第三方脚本
defer 的场景
- 页面主业务 JS
- 依赖 DOM 的脚本
- 多个有顺序依赖的脚本
总结
- 在项目里,大多数业务脚本更适合
defer,独立第三方脚本更适合async- 业务脚本中,可能会依赖页面的交互、DOM 操作,因此使用defer会更合适,等到DOM加载完才执行
- 第三方独立脚本通常不依赖页面主流程,也不强调执行顺序,所以更适合
async