跳到主要内容

脚本加载优化

为了提高首屏渲染加载页面,避免一些脚本在首屏直接加载

  • 降低首屏阻塞资源,减少非首屏必须 JS 的初始化成本

  • 第三方脚本,如facebook SDK、飞书登录sdk等等,这些统一按需加载

    • 避免在index.html直接引入脚本,会导致所有页面都会加载脚本
    • 首屏 JS 负担下降:非登录/非授权页面不再加载 FB/飞书 SDK。
  • 统一外部脚本加载方法,支持去重、缓存、失败重试能力

  • 第三方脚本加载统一治理,避免多处重复注入和竞态。

外部脚本加载方法​

js
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;
}

  • 使用
    • facebook
js
await loadExternalScript('https://connect.facebook.net/en_US/sdk.js', {
id: 'facebook-sdk',
attrs: {
crossorigin: 'anonymous',
},
});
  • 飞书登录脚本 (web扫码)
js
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