/** * @author Kuitos * @homepage https://github.com/kuitos/ * @since 2018-08-15 11:37 */ import { allSettledButCanBreak } from './allSettledButCanBreak'; import processTpl, { genLinkReplaceSymbol, genScriptReplaceSymbol } from './process-tpl'; import { defaultGetPublicPath, evalCode, getGlobalProp, getInlineCode, noteGlobalProps, readResAsString, requestIdleCallback, } from './utils'; const styleCache = {}; const scriptCache = {}; const embedHTMLCache = {}; if (!window.fetch) { throw new Error('[import-html-entry] Here is no "fetch" on the window env, you need to polyfill it'); } const defaultFetch = window.fetch.bind(window); function defaultGetTemplate(tpl) { return tpl; } /** * convert external css link to inline style for performance optimization * @param template * @param styles * @param opts * @return embedHTML */ function getEmbedHTML(template, styles, opts = {}) { const { fetch = defaultFetch } = opts; let embedHTML = template; return getExternalStyleSheets(styles, fetch) .then(styleSheets => { embedHTML = styleSheets.reduce((html, styleSheet) => { const styleSrc = styleSheet.src; const styleSheetContent = styleSheet.value; html = html.replace(genLinkReplaceSymbol(styleSrc), isInlineCode(styleSrc) ? `${styleSrc}` : ``); return html; }, embedHTML); return embedHTML; }); } const isInlineCode = code => code.startsWith('<'); function getExecutableScript(scriptSrc, scriptText, opts = {}) { const { proxy, strictGlobal, scopedGlobalVariables = [] } = opts; const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`; // 将 scopedGlobalVariables 拼接成变量声明,用于缓存全局变量,避免每次使用时都走一遍代理 const scopedGlobalVariableDefinition = scopedGlobalVariables.length ? `const {${scopedGlobalVariables.join(',')}}=this;` : ''; // 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上 // 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy const globalWindow = (0, eval)('window'); globalWindow.proxy = proxy; // TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并 return strictGlobal ? ( scopedGlobalVariableDefinition ? `;(function(){with(this){${scopedGlobalVariableDefinition}${scriptText}\n${sourceUrl}}}).bind(window.proxy)();` : `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);` ) : `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`; } // for prefetch export function getExternalStyleSheets(styles, fetch = defaultFetch) { return allSettledButCanBreak(styles.map(async styleLink => { if (isInlineCode(styleLink)) { // if it is inline style return getInlineCode(styleLink); } else { // external styles return styleCache[styleLink] || (styleCache[styleLink] = fetch(styleLink).then(response => { if (response.status >= 400) { throw new Error(`${styleLink} load failed with status ${response.status}`); } return response.text(); }).catch(e => { try { if (e.message.indexOf(styleLink) === -1) { e.message = `${styleLink} ${e.message}`; } } catch (_) { // e.message 可能是 readonly,此时会触发异常 } throw e; })); } }, )).then(results => results.map((result, i) => { if (result.status === 'fulfilled') { result.value = { src: styles[i], value: result.value, }; } return result; }).filter(result => { // 忽略失败的请求,避免异常下载阻塞后续资源加载 if (result.status === 'rejected') { Promise.reject(result.reason); } return result.status === 'fulfilled'; }).map(result => result.value)); } // for prefetch export function getExternalScripts(scripts, fetch = defaultFetch, entry) { const fetchScript = (scriptUrl, opts) => scriptCache[scriptUrl] || (scriptCache[scriptUrl] = fetch(scriptUrl, opts).then(response => { // usually browser treats 4xx and 5xx response of script loading as an error and will fire a script error event // https://stackoverflow.com/questions/5625420/what-http-headers-responses-trigger-the-onerror-handler-on-a-script-tag/5625603 if (response.status >= 400) { throw new Error(`${scriptUrl} load failed with status ${response.status}`); } return response.text(); }).catch(e => { try { if (e.message.indexOf(scriptUrl) === -1) { e.message = `${scriptUrl} ${e.message}`; } } catch (_) { // e.message 可能是 readonly,此时会触发异常 } throw e; })); // entry js 下载失败应该直接 break const shouldBreakWhileError = (i) => scripts[i] === entry; return allSettledButCanBreak(scripts.map(async script => { if (typeof script === 'string') { if (isInlineCode(script)) { // if it is inline script return getInlineCode(script); } else { // external script return fetchScript(script); } } else { // use idle time to load async script const { src, async, crossOrigin } = script; const fetchOpts = crossOrigin ? { credentials: 'include' } : {}; if (async) { return { src, async: true, content: new Promise((resolve, reject) => requestIdleCallback(() => fetchScript(src, fetchOpts).then(resolve, reject))), }; } return fetchScript(src, fetchOpts); } }, ), shouldBreakWhileError) .then(results => results.map((result, i) => { if (result.status === 'fulfilled') { result.value = { src: scripts[i], value: result.value, }; } return result; }).filter(result => { // 忽略失败的请求,避免异常下载阻塞后续资源加载 if (result.status === 'rejected') { Promise.reject(result.reason); } return result.status === 'fulfilled'; }).map(result => result.value)); } function throwNonBlockingError(error, msg) { setTimeout(() => { console.error(msg); throw error; }); } const supportsUserTiming = typeof performance !== 'undefined' && typeof performance.mark === 'function' && typeof performance.clearMarks === 'function' && typeof performance.measure === 'function' && typeof performance.clearMeasures === 'function'; /** * FIXME to consistent with browser behavior, we should only provide callback way to invoke success and error event * @param entry * @param scripts * @param proxy * @param opts * @returns {Promise} */ export function execScripts(entry, scripts, proxy = window, opts = {}) { const { fetch = defaultFetch, strictGlobal = false, success, error = () => { }, beforeExec = () => { }, afterExec = () => { }, scopedGlobalVariables = [], } = opts; return getExternalScripts(scripts, fetch, entry) .then(scriptsText => { const geval = (scriptSrc, inlineScript) => { const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript; const code = getExecutableScript(scriptSrc, rawCode, { proxy, strictGlobal, scopedGlobalVariables }); evalCode(scriptSrc, code); afterExec(inlineScript, scriptSrc); }; function exec(scriptSrc, inlineScript, resolve) { const markName = `Evaluating script ${scriptSrc}`; const measureName = `Evaluating Time Consuming: ${scriptSrc}`; if (process.env.NODE_ENV === 'development' && supportsUserTiming) { performance.mark(markName); } if (scriptSrc === entry) { noteGlobalProps(strictGlobal ? proxy : window); try { geval(scriptSrc, inlineScript); const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {}; resolve(exports); } catch (e) { // entry error must be thrown to make the promise settled console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`); throw e; } } else { if (typeof inlineScript === 'string') { try { if (scriptSrc?.src) { geval(scriptSrc.src, inlineScript); } else { geval(scriptSrc, inlineScript); } } catch (e) { // consistent with browser behavior, any independent script evaluation error should not block the others throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`); } } else { // external script marked with async inlineScript.async && inlineScript?.content .then(downloadedScriptText => geval(inlineScript.src, downloadedScriptText)) .catch(e => { throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`); }); } } if (process.env.NODE_ENV === 'development' && supportsUserTiming) { performance.measure(measureName, markName); performance.clearMarks(markName); performance.clearMeasures(measureName); } } function schedule(i, resolvePromise) { if (i < scriptsText.length) { const script = scriptsText[i]; const scriptSrc = script.src; const inlineScript = script.value; exec(scriptSrc, inlineScript, resolvePromise); // resolve the promise while the last script executed and entry not provided if (!entry && i === scriptsText.length - 1) { resolvePromise(); } else { schedule(i + 1, resolvePromise); } } } return new Promise(resolve => schedule(0, success || resolve)); }).catch((e) => { error(); throw e; }); } export default function importHTML(url, opts = {}) { let fetch = defaultFetch; let autoDecodeResponse = false; let getPublicPath = defaultGetPublicPath; let getTemplate = defaultGetTemplate; const { postProcessTemplate } = opts; // compatible with the legacy importHTML api if (typeof opts === 'function') { fetch = opts; } else { // fetch option is availble if (opts.fetch) { // fetch is a funciton if (typeof opts.fetch === 'function') { fetch = opts.fetch; } else { // configuration fetch = opts.fetch.fn || defaultFetch; autoDecodeResponse = !!opts.fetch.autoDecodeResponse; } } getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath; getTemplate = opts.getTemplate || defaultGetTemplate; } return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url) .then(response => readResAsString(response, autoDecodeResponse)) .then(html => { const assetPublicPath = getPublicPath(url); const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath, postProcessTemplate); return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({ template: embedHTML, assetPublicPath, getExternalScripts: () => getExternalScripts(scripts, fetch), getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), execScripts: (proxy, strictGlobal, opts = {}) => { if (!scripts.length) { return Promise.resolve(); } return execScripts(entry, scripts, proxy, { fetch, strictGlobal, ...opts, }); }, })); })); } export function importEntry(entry, opts = {}) { const { fetch = defaultFetch, getTemplate = defaultGetTemplate, postProcessTemplate } = opts; const getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath; if (!entry) { throw new SyntaxError('entry should not be empty!'); } // html entry if (typeof entry === 'string') { return importHTML(entry, { fetch, getPublicPath, getTemplate, postProcessTemplate, }); } // config entry if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) { const { scripts = [], styles = [], html = '' } = entry; const getHTMLWithStylePlaceholder = tpl => styles.reduceRight((html, styleSrc) => `${genLinkReplaceSymbol(styleSrc)}${html}`, tpl); const getHTMLWithScriptPlaceholder = tpl => scripts.reduce((html, scriptSrc) => `${html}${genScriptReplaceSymbol(scriptSrc)}`, tpl); return getEmbedHTML(getTemplate(getHTMLWithScriptPlaceholder(getHTMLWithStylePlaceholder(html))), styles, { fetch }).then(embedHTML => ({ template: embedHTML, assetPublicPath: getPublicPath(entry), getExternalScripts: () => getExternalScripts(scripts, fetch), getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), execScripts: (proxy, strictGlobal, opts = {}) => { if (!scripts.length) { return Promise.resolve(); } return execScripts(scripts[scripts.length - 1], scripts, proxy, { fetch, strictGlobal, ...opts, }); }, })); } else { throw new SyntaxError('entry scripts or styles should be array!'); } }