UNPKG

@micro-zoe/micro-app

Version:

A lightweight, efficient and powerful micro front-end framework

1,492 lines (1,485 loc) 385 kB
const version = '1.0.0-rc.24'; // do not use isUndefined const isBrowser = typeof window !== 'undefined'; // do not use isUndefined const globalThis = (typeof global !== 'undefined') ? global : ((typeof window !== 'undefined') ? window : ((typeof self !== 'undefined') ? self : Function('return this')())); const noopFalse = () => false; // Array.isArray const isArray = Array.isArray; // Object.assign const assign = Object.assign; // Object prototype methods const rawDefineProperty = Object.defineProperty; const rawDefineProperties = Object.defineProperties; const rawToString = Object.prototype.toString; const rawHasOwnProperty = Object.prototype.hasOwnProperty; const toTypeString = (value) => rawToString.call(value); // is Undefined function isUndefined(target) { return target === undefined; } // is Null function isNull(target) { return target === null; } // is String function isString(target) { return typeof target === 'string'; } // is Boolean function isBoolean(target) { return typeof target === 'boolean'; } // is Number function isNumber(target) { return typeof target === 'number'; } // is function function isFunction(target) { return typeof target === 'function'; } // is PlainObject function isPlainObject(target) { return toTypeString(target) === '[object Object]'; } // is Object function isObject(target) { return !isNull(target) && typeof target === 'object'; } // is Promise function isPromise(target) { return toTypeString(target) === '[object Promise]'; } // is bind function function isBoundFunction(target) { var _a; return isFunction(target) && ((_a = target.name) === null || _a === void 0 ? void 0 : _a.indexOf('bound ')) === 0 && !target.hasOwnProperty('prototype'); } // is constructor function function isConstructor(target) { var _a; if (isFunction(target)) { const targetStr = target.toString(); return (((_a = target.prototype) === null || _a === void 0 ? void 0 : _a.constructor) === target && Object.getOwnPropertyNames(target.prototype).length > 1) || /^function\s+[A-Z]/.test(targetStr) || /^class\s+/.test(targetStr); } return false; } // is ShadowRoot function isShadowRoot(target) { return typeof ShadowRoot !== 'undefined' && target instanceof ShadowRoot; } function isURL(target) { var _a; return target instanceof URL || !!((_a = target) === null || _a === void 0 ? void 0 : _a.href); } // iframe element not instanceof base app Element, use tagName instead function isElement(target) { var _a; return target instanceof Element || isString((_a = target) === null || _a === void 0 ? void 0 : _a.tagName); } // iframe node not instanceof base app Node, use nodeType instead function isNode(target) { var _a; return target instanceof Node || isNumber((_a = target) === null || _a === void 0 ? void 0 : _a.nodeType); } function isAnchorElement(target) { return toTypeString(target) === '[object HTMLAnchorElement]'; } function isAudioElement(target) { return toTypeString(target) === '[object HTMLAudioElement]'; } function isVideoElement(target) { return toTypeString(target) === '[object HTMLVideoElement]'; } function isLinkElement(target) { return toTypeString(target) === '[object HTMLLinkElement]'; } function isBodyElement(target) { return toTypeString(target) === '[object HTMLBodyElement]'; } function isStyleElement(target) { return toTypeString(target) === '[object HTMLStyleElement]'; } function isScriptElement(target) { return toTypeString(target) === '[object HTMLScriptElement]'; } function isIFrameElement(target) { return toTypeString(target) === '[object HTMLIFrameElement]'; } function isDivElement(target) { return toTypeString(target) === '[object HTMLDivElement]'; } function isImageElement(target) { return toTypeString(target) === '[object HTMLImageElement]'; } function isBaseElement(target) { return toTypeString(target) === '[object HTMLBaseElement]'; } function isDocumentFragment(target) { return toTypeString(target) === '[object DocumentFragment]'; } function isDocumentShadowRoot(target) { return toTypeString(target) === '[object ShadowRoot]'; } function isMicroAppBody(target) { return isElement(target) && target.tagName.toUpperCase() === 'MICRO-APP-BODY'; } function isMicroAppHead(target) { return isElement(target) && target.tagName.toUpperCase() === 'MICRO-APP-HEAD'; } function isWebComponentElement(target) { let result = toTypeString(target) === '[object HTMLElement]'; if (result) { const tagName = target.tagName.toUpperCase(); result = result && !tagName.startsWith('MICRO-APP'); } return result; } // is ProxyDocument function isProxyDocument(target) { return toTypeString(target) === '[object ProxyDocument]'; } function isTargetExtension(path, suffix) { try { return createURL(path).pathname.split('.').pop() === suffix; } catch (_a) { return false; } } function includes(target, searchElement, fromIndex) { if (target == null) { throw new TypeError('includes target is null or undefined'); } const O = Object(target); const len = parseInt(O.length, 10) || 0; if (len === 0) return false; // @ts-ignore fromIndex = parseInt(fromIndex, 10) || 0; let i = Math.max(fromIndex >= 0 ? fromIndex : len + fromIndex, 0); while (i < len) { // NaN !== NaN if (searchElement === O[i] || (searchElement !== searchElement && O[i] !== O[i])) { return true; } i++; } return false; } /** * format error log * @param msg message * @param appName app name, default is null */ function logError(msg, appName = null, ...rest) { const appNameTip = appName && isString(appName) ? ` app ${appName}:` : ''; if (isString(msg)) { console.error(`[micro-app]${appNameTip} ${msg}`, ...rest); } else { console.error(`[micro-app]${appNameTip}`, msg, ...rest); } } /** * format warn log * @param msg message * @param appName app name, default is null */ function logWarn(msg, appName = null, ...rest) { const appNameTip = appName && isString(appName) ? ` app ${appName}:` : ''; if (isString(msg)) { console.warn(`[micro-app]${appNameTip} ${msg}`, ...rest); } else { console.warn(`[micro-app]${appNameTip}`, msg, ...rest); } } /** * async execution * @param fn callback * @param args params */ function defer(fn, ...args) { Promise.resolve().then(fn.bind(null, ...args)); } /** * async execution with macro task * @param fn callback * @param args params */ function macro(fn, delay = 0, ...args) { setTimeout(fn.bind(null, ...args), delay); } /** * create URL as MicroLocation */ const createURL = (function () { class Location extends URL { } return (path, base) => { return (base ? new Location('' + path, base) : new Location('' + path)); }; })(); /** * Add address protocol * @param url address */ function addProtocol(url) { return url.startsWith('//') ? `${globalThis.location.protocol}${url}` : url; } /** * format URL address * note the scenes: * 1. micro-app -> attributeChangedCallback * 2. preFetch */ function formatAppURL(url, appName = null) { if (!isString(url) || !url) return ''; try { const { origin, pathname, search } = createURL(addProtocol(url), (window.rawWindow || window).location.href); /** * keep the original url unchanged, such as .html .node .php .net .etc, search, except hash * BUG FIX: Never using '/' to complete url, refer to https://github.com/jd-opensource/micro-app/issues/1147 */ const fullPath = `${origin}${pathname}${search}`; return /^https?:\/\//.test(fullPath) ? fullPath : ''; } catch (e) { logError(e, appName); return ''; } } /** * format name * note the scenes: * 1. micro-app -> attributeChangedCallback * 2. event_center -> EventCenterForMicroApp -> constructor * 3. event_center -> EventCenterForBaseApp -> all methods * 4. preFetch * 5. plugins * 6. router api (push, replace) */ function formatAppName(name) { if (!isString(name) || !name) return ''; return name.replace(/(^\d+)|([^\w\d-_])/gi, ''); } /** * Get valid address, such as * 1. https://domain/xx/xx.html to https://domain/xx/ * 2. https://domain/xx to https://domain/xx/ * @param url app.url */ function getEffectivePath(url) { const { origin, pathname } = createURL(url); if (/\.(\w+)$/.test(pathname)) { const pathArr = `${origin}${pathname}`.split('/'); pathArr.pop(); return pathArr.join('/') + '/'; } return `${origin}${pathname}/`.replace(/\/\/$/, '/'); } /** * Complete address * @param path address * @param baseURI base url(app.url) */ function CompletionPath(path, baseURI) { if (!path || /^((((ht|f)tps?)|file):)?\/\//.test(path) || /^(data|blob):/.test(path)) return path; return createURL(path, getEffectivePath(addProtocol(baseURI))).toString(); } /** * Get the folder where the link resource is located, * which is used to complete the relative address in the css * @param linkPath full link address */ function getLinkFileDir(linkPath) { const pathArr = linkPath.split('/'); pathArr.pop(); return addProtocol(pathArr.join('/') + '/'); } /** * promise stream * @param promiseList promise list * @param successCb success callback * @param errorCb failed callback * @param finallyCb finally callback */ function promiseStream(promiseList, successCb, errorCb, finallyCb) { let finishedNum = 0; function isFinished() { if (++finishedNum === promiseList.length && finallyCb) finallyCb(); } promiseList.forEach((p, i) => { if (isPromise(p)) { p.then((res) => { successCb({ data: res, index: i }); isFinished(); }).catch((err) => { errorCb({ error: err, index: i }); isFinished(); }); } else { successCb({ data: p, index: i }); isFinished(); } }); } // Check whether the browser supports module script function isSupportModuleScript() { const s = document.createElement('script'); return 'noModule' in s; } // Create a random symbol string function createNonceSrc() { return 'inline-' + Math.random().toString(36).substr(2, 15); } // Array deduplication function unique(array) { return array.filter(function (item) { return item in this ? false : (this[item] = true); }, Object.create(null)); } // requestIdleCallback polyfill const requestIdleCallback = globalThis.requestIdleCallback || function (fn) { const lastTime = Date.now(); return setTimeout(function () { fn({ didTimeout: false, timeRemaining() { return Math.max(0, 50 - (Date.now() - lastTime)); }, }); }, 1); }; /** * Wrap requestIdleCallback with promise * Exec callback when browser idle */ function promiseRequestIdle(callback) { return new Promise((resolve) => { requestIdleCallback(() => { callback(resolve); }); }); } /** * Record the currently running app.name */ let currentAppName = null; function setCurrentAppName(appName) { currentAppName = appName; } // get the currently running app.name function getCurrentAppName() { return currentAppName; } function throttleDeferForSetAppName(appName) { if (currentAppName !== appName && !getPreventSetState()) { setCurrentAppName(appName); defer(() => { setCurrentAppName(null); }); } } // only for iframe document.body(head).querySelector(querySelectorAll) let iframeCurrentAppName = null; function setIframeCurrentAppName(appName) { iframeCurrentAppName = appName; } function getIframeCurrentAppName() { return iframeCurrentAppName; } function throttleDeferForIframeAppName(appName) { if (iframeCurrentAppName !== appName && !getPreventSetState()) { setIframeCurrentAppName(appName); defer(() => { setIframeCurrentAppName(null); }); } } // prevent set app name let preventSetState = false; function getPreventSetState() { return preventSetState; } /** * prevent set appName * usage: * removeDomScope(true) * -----> element scope point to base app <----- * removeDomScope(false) */ function removeDomScope(force) { if (force !== false) { setCurrentAppName(null); setIframeCurrentAppName(null); if (force && !preventSetState) { preventSetState = true; defer(() => { preventSetState = false; }); } } else { preventSetState = false; } } /** * Create pure elements */ function pureCreateElement(tagName, options) { const element = (window.rawDocument || document).createElement(tagName, options); if (element.__MICRO_APP_NAME__) delete element.__MICRO_APP_NAME__; element.__PURE_ELEMENT__ = true; return element; } // is invalid key of querySelector function isInvalidQuerySelectorKey(key) { return !key || /(^\d)|([^\w\d-_\u4e00-\u9fa5])/gi.test(key); } // unique element function isUniqueElement(key) { return (/^body$/i.test(key) || /^head$/i.test(key) || /^html$/i.test(key) || /^title$/i.test(key) || /^:root$/i.test(key)); } /** * get micro-app element * @param target app container */ function getRootContainer(target) { return (isShadowRoot(target) ? target.host : target); } /** * trim start & end */ function trim(str) { return str ? str.replace(/^\s+|\s+$/g, '') : ''; } function isFireFox() { return navigator.userAgent.indexOf('Firefox') > -1; } /** * Transforms a queryString into object. * @param search - search string to parse * @returns a query object */ function parseQuery(search) { const result = {}; const queryList = search.split('&'); // we will not decode the key/value to ensure that the values are consistent when update URL for (const queryItem of queryList) { const eqPos = queryItem.indexOf('='); const key = eqPos < 0 ? queryItem : queryItem.slice(0, eqPos); const value = eqPos < 0 ? null : queryItem.slice(eqPos + 1); if (key in result) { let currentValue = result[key]; if (!isArray(currentValue)) { currentValue = result[key] = [currentValue]; } currentValue.push(value); } else { result[key] = value; } } return result; } /** * Transforms an object to query string * @param queryObject - query object to stringify * @returns query string without the leading `?` */ function stringifyQuery(queryObject) { let result = ''; for (const key in queryObject) { const value = queryObject[key]; if (isNull(value)) { result += (result.length ? '&' : '') + key; } else { const valueList = isArray(value) ? value : [value]; valueList.forEach(value => { if (!isUndefined(value)) { result += (result.length ? '&' : '') + key; if (!isNull(value)) result += '=' + value; } }); } } return result; } /** * Register or unregister callback/guard with Set */ function useSetRecord() { const handlers = new Set(); function add(handler) { handlers.add(handler); return () => { if (handlers.has(handler)) return handlers.delete(handler); return false; }; } return { add, list: () => handlers, }; } /** * record data with Map */ function useMapRecord() { const data = new Map(); function add(key, value) { data.set(key, value); return () => { if (data.has(key)) return data.delete(key); return false; }; } return { add, get: (key) => data.get(key), delete: (key) => { if (data.has(key)) return data.delete(key); return false; } }; } function getAttributes(element) { const attr = element.attributes; const attrMap = new Map(); for (let i = 0; i < attr.length; i++) { attrMap.set(attr[i].name, attr[i].value); } return attrMap; } /** * if fiberTasks exist, wrap callback with promiseRequestIdle * if not, execute callback * @param fiberTasks fiber task list * @param callback action callback */ function injectFiberTask(fiberTasks, callback) { if (fiberTasks) { fiberTasks.push(() => promiseRequestIdle((resolve) => { callback(); resolve(); })); } else { callback(); } } /** * serial exec fiber task of link, style, script * @param tasks task array or null */ function serialExecFiberTasks(tasks) { return (tasks === null || tasks === void 0 ? void 0 : tasks.reduce((pre, next) => pre.then(next), Promise.resolve())) || null; } /** * inline script start with inline-xxx * @param address source address */ function isInlineScript(address) { return address.startsWith('inline-'); } /** * call function with try catch * @param fn target function * @param appName app.name * @param args arguments */ function execMicroAppGlobalHook(fn, appName, hookName, ...args) { try { isFunction(fn) && fn(...args); } catch (e) { logError(`An error occurred in app ${appName} window.${hookName} \n`, null, e); } } /** * remove all childNode from target node * @param $dom target node */ function clearDOM($dom) { while ($dom === null || $dom === void 0 ? void 0 : $dom.firstChild) { $dom.removeChild($dom.firstChild); } } function instanceOf(instance, constructor) { if (instance === null || instance === undefined) { return false; } else if (!isFunction(constructor)) { throw new TypeError("Right-hand side of 'instanceof' is not callable"); } else if (typeof instance === 'number' || typeof instance === 'string' || typeof instance === 'boolean') { // 检查 obj 是否是基本类型的包装器实例 return false; } let proto = Object.getPrototypeOf(instance); while (proto) { if (proto === constructor.prototype) { return true; } proto = Object.getPrototypeOf(proto); } return false; } /** * Format event name * In with sandbox, child event and lifeCycles bind to microAppElement, there are two events with same name - mounted unmount, it should be handled specifically to prevent conflicts * Issue: https://github.com/jd-opensource/micro-app/issues/1161 * @param type event name * @param appName app name */ const formatEventList = ['mounted', 'unmount']; function formatEventType(type, appName) { return formatEventList.includes(type) ? `${type}-${appName}` : type; } /** * Is the object empty * target maybe number, string, array ... */ function isEmptyObject(target) { return isPlainObject(target) ? !Object.keys(target).length : true; } function formatEventInfo(event, element) { Object.defineProperties(event, { currentTarget: { get() { return element; } }, target: { get() { return element; } }, }); } /** * dispatch lifeCycles event to base app * created, beforemount, mounted, unmount, error * @param element container * @param appName app.name * @param lifecycleName lifeCycle name * @param error param from error hook */ function dispatchLifecyclesEvent(element, appName, lifecycleName, error) { var _a; if (!element) { return logWarn(`element does not exist in lifecycle ${lifecycleName}`, appName); } element = getRootContainer(element); // clear dom scope before dispatch lifeCycles event to base app, especially mounted & unmount removeDomScope(); const detail = assign({ name: appName, container: element, }, error && { error }); const event = new CustomEvent(lifecycleName, { detail, }); formatEventInfo(event, element); // global hooks if (isFunction((_a = microApp.options.lifeCycles) === null || _a === void 0 ? void 0 : _a[lifecycleName])) { microApp.options.lifeCycles[lifecycleName](event, appName); } element.dispatchEvent(event); } /** * Dispatch custom event to micro app * @param app app * @param eventName event name ['mounted', 'unmount', 'appstate-change', 'statechange'] * @param detail event detail */ function dispatchCustomEventToMicroApp(app, eventName, detail = {}) { var _a; const event = new CustomEvent(formatEventType(eventName, app.name), { detail, }); (_a = app.sandBox) === null || _a === void 0 ? void 0 : _a.microAppWindow.dispatchEvent(event); } /** * fetch source of html, js, css * @param url source path * @param appName app name * @param config fetch options */ function fetchSource(url, appName = null, options = {}) { /** * When child navigate to new async page, click event will scope dom to child and then fetch new source * this may cause error when fetch rewrite by baseApp * e.g. * baseApp: <script crossorigin src="https://sgm-static.jd.com/sgm-2.8.0.js" name="SGMH5" sid="6f88a6e4ba4b4ae5acef2ec22c075085" appKey="jdb-adminb2b-pc"></script> */ removeDomScope(); if (isFunction(microApp.options.fetch)) { return microApp.options.fetch(url, options, appName); } // Don’t use globalEnv.rawWindow.fetch, will cause sgm-2.8.0.js throw error in nest app return window.fetch(url, options).then((res) => { return res.text(); }); } class HTMLLoader { static getInstance() { if (!this.instance) { this.instance = new HTMLLoader(); } return this.instance; } /** * run logic of load and format html * @param successCb success callback * @param errorCb error callback, type: (err: Error, meetFetchErr: boolean) => void */ run(app, successCb) { const appName = app.name; const htmlUrl = app.ssrUrl || app.url; const isJsResource = isTargetExtension(htmlUrl, 'js'); const htmlPromise = isJsResource ? Promise.resolve(`<micro-app-head><script src='${htmlUrl}'></script></micro-app-head><micro-app-body></micro-app-body>`) : fetchSource(htmlUrl, appName, { cache: 'no-cache' }); htmlPromise.then((htmlStr) => { if (!htmlStr) { const msg = 'html is empty, please check in detail'; app.onerror(new Error(msg)); return logError(msg, appName); } htmlStr = this.formatHTML(htmlUrl, htmlStr, appName); successCb(htmlStr, app); }).catch((e) => { logError(`Failed to fetch data from ${app.url}, micro-app stop rendering`, appName, e); app.onLoadError(e); }); } formatHTML(htmlUrl, htmlStr, appName) { return this.processHtml(htmlUrl, htmlStr, appName, microApp.options.plugins) .replace(/<head[^>]*>[\s\S]*?<\/head>/i, (match) => { return match .replace(/<head/i, '<micro-app-head') .replace(/<\/head>/i, '</micro-app-head>'); }) .replace(/<body[^>]*>[\s\S]*?<\/body>/i, (match) => { return match .replace(/<body/i, '<micro-app-body') .replace(/<\/body>/i, '</micro-app-body>'); }); } processHtml(url, code, appName, plugins) { var _a; if (!plugins) return code; const mergedPlugins = []; plugins.global && mergedPlugins.push(...plugins.global); ((_a = plugins.modules) === null || _a === void 0 ? void 0 : _a[appName]) && mergedPlugins.push(...plugins.modules[appName]); if (mergedPlugins.length > 0) { return mergedPlugins.reduce((preCode, plugin) => { if (isPlainObject(plugin) && isFunction(plugin.processHtml)) { return plugin.processHtml(preCode, url); } return preCode; }, code); } return code; } } // common reg const rootSelectorREG = /(^|\s+)(html|:root)(?=[\s>~[.#:]+|$)/; const bodySelectorREG = /(^|\s+)((html[\s>~]+body)|body)(?=[\s>~[.#:]+|$)/; function parseError(msg, linkPath) { msg = linkPath ? `${linkPath} ${msg}` : msg; const err = new Error(msg); err.reason = msg; if (linkPath) { err.filename = linkPath; } throw err; } /** * Reference https://github.com/reworkcss/css * CSSParser mainly deals with 3 scenes: styleRule, @, and comment * And scopecss deals with 2 scenes: selector & url * And can also disable scopecss with inline comments */ class CSSParser { constructor() { this.cssText = ''; // css content this.prefix = ''; // prefix as micro-app[name=xxx] this.baseURI = ''; // domain name this.linkPath = ''; // link resource address, if it is the style converted from link, it will have linkPath this.result = ''; // parsed cssText this.scopecssDisable = false; // use block comments /* scopecss-disable */ to disable scopecss in your file, and use /* scopecss-enable */ to enable scopecss this.scopecssDisableSelectors = []; // disable or enable scopecss for specific selectors this.scopecssDisableNextLine = false; // use block comments /* scopecss-disable-next-line */ to disable scopecss on a specific line // https://developer.mozilla.org/en-US/docs/Web/API/CSSMediaRule this.mediaRule = this.createMatcherForRuleWithChildRule(/^@media *([^{]+)/, '@media'); // https://developer.mozilla.org/en-US/docs/Web/API/CSSSupportsRule this.supportsRule = this.createMatcherForRuleWithChildRule(/^@supports *([^{]+)/, '@supports'); this.documentRule = this.createMatcherForRuleWithChildRule(/^@([-\w]+)?document *([^{]+)/, '@document'); this.hostRule = this.createMatcherForRuleWithChildRule(/^@host\s*/, '@host'); // :global is CSS Modules rule, it will be converted to normal syntax // private globalRule = this.createMatcherForRuleWithChildRule(/^:global([^{]*)/, ':global') // https://developer.mozilla.org/en-US/docs/Web/API/CSSImportRule this.importRule = this.createMatcherForNoneBraceAtRule('import'); // Removed in most browsers this.charsetRule = this.createMatcherForNoneBraceAtRule('charset'); // https://developer.mozilla.org/en-US/docs/Web/API/CSSNamespaceRule this.namespaceRule = this.createMatcherForNoneBraceAtRule('namespace'); // https://developer.mozilla.org/en-US/docs/Web/CSS/@container this.containerRule = this.createMatcherForRuleWithChildRule(/^@container *([^{]+)/, '@container'); } exec(cssText, prefix, baseURI, linkPath) { this.cssText = cssText; this.prefix = prefix; this.baseURI = baseURI; this.linkPath = linkPath || ''; this.matchRules(); return isFireFox() ? decodeURIComponent(this.result) : this.result; } reset() { this.cssText = this.prefix = this.baseURI = this.linkPath = this.result = ''; this.scopecssDisable = this.scopecssDisableNextLine = false; this.scopecssDisableSelectors = []; } // core action for match rules matchRules() { this.matchLeadingSpaces(); this.matchComments(); while (this.cssText.length && this.cssText.charAt(0) !== '}' && (this.matchAtRule() || this.matchStyleRule())) { this.matchComments(); } } // https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleRule matchStyleRule() { const selectors = this.formatSelector(true); // reset scopecssDisableNextLine this.scopecssDisableNextLine = false; if (!selectors) return this.printError('selector missing', this.linkPath); this.recordResult(selectors); this.matchComments(); this.styleDeclarations(); this.matchLeadingSpaces(); return true; } formatSelector(skip) { const m = this.commonMatch(/^[^{]+/, skip); if (!m) return false; /** * NOTE: * 1. :is(h1, h2, h3):has(+ h2, + h3, + h4) {} * should be ==> micro-app[name=xxx] :is(h1, h2, h3):has(+ h2, + h3, + h4) {} * 2. :dir(ltr) {} * should be ==> micro-app[name=xxx] :dir(ltr) {} * 3. body :not(div, .fancy) {} * should be ==> micro-app[name=xxx] micro-app-body :not(div, .fancy) {} * 4. .a, .b, li:nth-child(3) * should be ==> micro-app[name=xxx] .a, micro-app[name=xxx] .b, micro-app[name=xxx] li:nth-child(3) * 5. :is(.a, .b, .c) a {} * should be ==> micro-app[name=xxx] :is(.a, .b, .c) a {} * 6. :where(.a, .b, .c) a {} * should be ==> micro-app[name=xxx] :where(.a, .b, .c) a {} */ const attributeValues = {}; const matchRes = m[0].replace(/\[([^\]=]+)(?:=([^\]]+))?\]/g, (match, p1, p2) => { const mock = `__mock_${p1}Value__`; attributeValues[mock] = p2; return match.replace(p2, mock); }); return matchRes.replace(/(^|,[\n\s]*)([^,]+)/g, (_, separator, selector) => { selector = trim(selector); selector = selector.replace(/\[[^\]=]+(?:=([^\]]+))?\]/g, (match, p1) => { if (attributeValues[p1]) { return match.replace(p1, attributeValues[p1]); } return match; }); if (selector && !(this.scopecssDisableNextLine || (this.scopecssDisable && (!this.scopecssDisableSelectors.length || this.scopecssDisableSelectors.includes(selector))) || rootSelectorREG.test(selector))) { if (bodySelectorREG.test(selector)) { selector = selector.replace(bodySelectorREG, this.prefix + ' micro-app-body'); } else { selector = this.prefix + ' ' + selector; } } return separator + selector; }); } // https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleDeclaration styleDeclarations() { if (!this.matchOpenBrace()) return this.printError("Declaration missing '{'", this.linkPath); this.matchAllDeclarations(); if (!this.matchCloseBrace()) return this.printError("Declaration missing '}'", this.linkPath); return true; } matchAllDeclarations(nesting = 0) { let cssValue = this.commonMatch(/^(?:url\(["']?(?:[^)"'}]+)["']?\)|[^{}/])*/, true)[0]; if (cssValue) { if (!this.scopecssDisableNextLine && (!this.scopecssDisable || this.scopecssDisableSelectors.length)) { cssValue = cssValue.replace(/url\((["']?)(.*?)\1\)/gm, (all, _, $1) => { if (/^((data|blob):|#|%23)/.test($1) || /^(https?:)?\/\//.test($1)) { return all; } // ./a/b.png ../a/b.png a/b.png if (/^((\.\.?\/)|[^/])/.test($1) && this.linkPath) { this.baseURI = getLinkFileDir(this.linkPath); } return `url("${CompletionPath($1, this.baseURI)}")`; }); } this.recordResult(cssValue); } // reset scopecssDisableNextLine this.scopecssDisableNextLine = false; if (!this.cssText.length) return; // extract comments in declarations if (this.cssText.charAt(0) === '/') { if (this.cssText.charAt(1) === '*') { this.matchComments(); } else { this.commonMatch(/\/+/); } } else if (this.cssText.charAt(0) === '{') { this.matchOpenBrace(); nesting++; } else if (this.cssText.charAt(0) === '}') { if (nesting < 1) return; this.matchCloseBrace(); nesting--; } return this.matchAllDeclarations(nesting); } matchAtRule() { if (this.cssText[0] !== '@') return false; // reset scopecssDisableNextLine this.scopecssDisableNextLine = false; return this.keyframesRule() || this.mediaRule() || this.customMediaRule() || this.supportsRule() || this.importRule() || this.charsetRule() || this.namespaceRule() || this.containerRule() || this.documentRule() || this.pageRule() || this.hostRule() || this.fontFaceRule() || this.layerRule(); } // :global is CSS Modules rule, it will be converted to normal syntax // private matchGlobalRule (): boolean | void { // if (this.cssText[0] !== ':') return false // // reset scopecssDisableNextLine // this.scopecssDisableNextLine = false // return this.globalRule() // } // https://developer.mozilla.org/en-US/docs/Web/API/CSSKeyframesRule keyframesRule() { if (!this.commonMatch(/^@([-\w]+)?keyframes\s*/)) return false; if (!this.commonMatch(/^[^{]+/)) return this.printError('@keyframes missing name', this.linkPath); this.matchComments(); if (!this.matchOpenBrace()) return this.printError("@keyframes missing '{'", this.linkPath); this.matchComments(); while (this.keyframeRule()) { this.matchComments(); } if (!this.matchCloseBrace()) return this.printError("@keyframes missing '}'", this.linkPath); this.matchLeadingSpaces(); return true; } keyframeRule() { let r; const valList = []; while (r = this.commonMatch(/^((\d+\.\d+|\.\d+|\d+)%?|[a-z]+)\s*/)) { valList.push(r[1]); this.commonMatch(/^,\s*/); } if (!valList.length) return false; this.styleDeclarations(); this.matchLeadingSpaces(); return true; } // https://github.com/postcss/postcss-custom-media customMediaRule() { if (!this.commonMatch(/^@custom-media\s+(--[^\s]+)\s*([^{;]+);/)) return false; this.matchLeadingSpaces(); return true; } // https://developer.mozilla.org/en-US/docs/Web/API/CSSPageRule pageRule() { if (!this.commonMatch(/^@page */)) return false; this.formatSelector(false); // reset scopecssDisableNextLine this.scopecssDisableNextLine = false; return this.commonHandlerForAtRuleWithSelfRule('page'); } // https://developer.mozilla.org/en-US/docs/Web/API/CSSFontFaceRule fontFaceRule() { if (!this.commonMatch(/^@font-face\s*/)) return false; return this.commonHandlerForAtRuleWithSelfRule('font-face'); } // https://developer.mozilla.org/en-US/docs/Web/CSS/@layer layerRule() { if (!this.commonMatch(/^@layer\s*([^{;]+)/)) return false; if (!this.matchOpenBrace()) return !!this.commonMatch(/^[;]+/); this.matchComments(); this.matchRules(); if (!this.matchCloseBrace()) return this.printError('@layer missing \'}\'', this.linkPath); this.matchLeadingSpaces(); return true; } // common matcher for @media, @supports, @document, @host, :global, @container createMatcherForRuleWithChildRule(reg, name) { return () => { if (!this.commonMatch(reg)) return false; if (!this.matchOpenBrace()) return this.printError(`${name} missing '{'`, this.linkPath); this.matchComments(); this.matchRules(); if (!this.matchCloseBrace()) return this.printError(`${name} missing '}'`, this.linkPath); this.matchLeadingSpaces(); return true; }; } // common matcher for @import, @charset, @namespace createMatcherForNoneBraceAtRule(name) { const reg = new RegExp('^@' + name + '\\s*([^;]+);'); return () => { if (!this.commonMatch(reg)) return false; this.matchLeadingSpaces(); return true; }; } // common handler for @font-face, @page commonHandlerForAtRuleWithSelfRule(name) { if (!this.matchOpenBrace()) return this.printError(`@${name} missing '{'`, this.linkPath); this.matchAllDeclarations(); if (!this.matchCloseBrace()) return this.printError(`@${name} missing '}'`, this.linkPath); this.matchLeadingSpaces(); return true; } // match and slice comments matchComments() { while (this.matchComment()) ; } // css comment matchComment() { if (this.cssText.charAt(0) !== '/' || this.cssText.charAt(1) !== '*') return false; // reset scopecssDisableNextLine this.scopecssDisableNextLine = false; let i = 2; while (this.cssText.charAt(i) !== '' && (this.cssText.charAt(i) !== '*' || this.cssText.charAt(i + 1) !== '/')) ++i; i += 2; if (this.cssText.charAt(i - 1) === '') { return this.printError('End of comment missing', this.linkPath); } // get comment content let commentText = this.cssText.slice(2, i - 2); this.recordResult(`/*${commentText}*/`); commentText = trim(commentText.replace(/^\s*!/, '')); // set ignore config if (commentText === 'scopecss-disable-next-line') { this.scopecssDisableNextLine = true; } else if (/^scopecss-disable/.test(commentText)) { if (commentText === 'scopecss-disable') { this.scopecssDisable = true; } else { this.scopecssDisable = true; const ignoreRules = commentText.replace('scopecss-disable', '').split(','); ignoreRules.forEach((rule) => { this.scopecssDisableSelectors.push(trim(rule)); }); } } else if (commentText === 'scopecss-enable') { this.scopecssDisable = false; this.scopecssDisableSelectors = []; } this.cssText = this.cssText.slice(i); this.matchLeadingSpaces(); return true; } commonMatch(reg, skip = false) { const matchArray = reg.exec(this.cssText); if (!matchArray) return; const matchStr = matchArray[0]; this.cssText = this.cssText.slice(matchStr.length); if (!skip) this.recordResult(matchStr); return matchArray; } matchOpenBrace() { return this.commonMatch(/^{\s*/); } matchCloseBrace() { return this.commonMatch(/^}\s*/); } // match and slice the leading spaces matchLeadingSpaces() { this.commonMatch(/^\s*/); } // splice string recordResult(strFragment) { // Firefox performance degradation when string contain special characters, see https://github.com/jd-opensource/micro-app/issues/256 if (isFireFox()) { this.result += encodeURIComponent(strFragment); } else { this.result += strFragment; } } printError(msg, linkPath) { if (this.cssText.length) { parseError(msg, linkPath); } } } /** * common method of bind CSS */ function commonAction(styleElement, appName, prefix, baseURI, linkPath) { if (!styleElement.__MICRO_APP_HAS_SCOPED__) { styleElement.__MICRO_APP_HAS_SCOPED__ = true; let result = null; try { result = parser.exec(styleElement.textContent, prefix, baseURI, linkPath); parser.reset(); } catch (e) { parser.reset(); logError('An error occurred while parsing CSS:\n', appName, e); } if (result) styleElement.textContent = result; } } let parser; /** * scopedCSS * @param styleElement target style element * @param appName app name */ function scopedCSS(styleElement, app, linkPath) { if (app.scopecss) { const prefix = createPrefix(app.name); if (!parser) parser = new CSSParser(); const escapeRegExp = (regStr) => regStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (styleElement.textContent) { commonAction(styleElement, app.name, prefix, app.url, linkPath); const observer = new MutationObserver(() => { const escapedPrefix = escapeRegExp(prefix); const isPrefixed = styleElement.textContent && new RegExp(escapedPrefix).test(styleElement.textContent); observer.disconnect(); if (!isPrefixed) { styleElement.__MICRO_APP_HAS_SCOPED__ = false; scopedCSS(styleElement, app, linkPath); } }); observer.observe(styleElement, { childList: true, characterData: true }); } else { const observer = new MutationObserver(function () { observer.disconnect(); // styled-component will be ignore if (styleElement.textContent && !styleElement.hasAttribute('data-styled')) { commonAction(styleElement, app.name, prefix, app.url, linkPath); } }); observer.observe(styleElement, { childList: true }); } } return styleElement; } function createPrefix(appName, reg = false) { const regCharacter = reg ? '\\' : ''; return `${microApp.tagName}${regCharacter}[name=${appName}${regCharacter}]`; } function eventHandler(event, element) { Object.defineProperties(event, { currentTarget: { get() { return element; } }, srcElement: { get() { return element; } }, target: { get() { return element; } }, }); } function dispatchOnLoadEvent(element) { const event = new CustomEvent('load'); eventHandler(event, element); if (isFunction(element.onload)) { element.onload(event); } else { element.dispatchEvent(event); } } function dispatchOnErrorEvent(element) { const event = new CustomEvent('error'); eventHandler(event, element); if (isFunction(element.onerror)) { element.onerror(event); } else { element.dispatchEvent(event); } } /** * SourceCenter is a resource management center * All html, js, css will be recorded and processed here * NOTE: * 1. All resources are global and shared between apps * 2. Pay attention to the case of html with parameters * 3. The resource is first processed by the plugin */ function createSourceCenter() { const linkList = new Map(); const scriptList = new Map(); function createSourceHandler(targetList) { return { setInfo(address, info) { targetList.set(address, info); }, getInfo(address) { var _a; return (_a = targetList.get(address)) !== null && _a !== void 0 ? _a : null; }, hasInfo(address) { return targetList.has(address); }, deleteInfo(address) { return targetList.delete(address); } }; } return { link: createSourceHandler(linkList), script: Object.assign(Object.assign({}, createSourceHandler(scriptList)), { deleteInlineInfo(addressList) { addressList.forEach((address) => { if (isInlineScript(address)) { scriptList.delete(address); } }); } }), }; } var sourceCenter = createSourceCenter(); /** * * @param appName app.name * @param linkInfo linkInfo of current address */ function getExistParseCode(appName, prefix, linkInfo) { const appSpace = linkInfo.appSpace; for (const item in appSpace) { if (item !== appName) { const appSpaceData = appSpace[item]; if (appSpaceData.parsedCode) { return appSpaceData.parsedCode.replace(new RegExp(createPrefix(item, true), 'g'), prefix); } } } } // transfer the attributes on the link to convertStyle function setConvertStyleAttr(convertStyle, attrs) { attrs.forEach((value, key) => { if (key === 'rel') return; if (key === 'href') key = 'data-origin-href'; globalEnv.rawSetAttribute.call(convertStyle, key, value); }); } /** * Extract link elements * @param link link element * @param parent parent element of link * @param app app * @param microAppHead micro-app-head element * @param isDynamic dynamic insert */ function extractLinkFromHtml(link, parent, app, isDynamic = false) { const rel = link.getAttribute('rel'); let href = link.getAttribute('href'); let replaceComment = null; if (rel === 'stylesheet' && href) { href = CompletionPath(href, app.url); let linkInfo = sourceCenter.link.getInfo(href); const appSpaceData = { attrs: getAttributes(link), }; if (!linkInfo) { linkInfo = { code: '', appSpace: { [app.name]: appSpaceData, } }; } else { linkInfo.appSpace[app.name] = linkInfo.appSpace[app.name] || appSpaceData; } sourceCenter.link.setInfo(href, linkInfo); if (!isDynamic) { app.source.links.add(href); replaceComment = document.createComment(`link element with href=${href} move to micro-app-head as style element`); linkInfo.appSpace[app.name].placeholder = replaceComment; } else { return { address: href, linkInfo }; } } else if (rel && ['prefetch', 'preload', 'prerender', 'modulepreload', 'icon'].includes(rel)) { // preload prefetch prerender .... if (isDynamic) { replaceComment = document.createComment(`link element with rel=${rel}${href ? ' & href=' + href : ''} removed by micro-app`); } else { parent === null || parent === void 0 ? void 0 : parent.removeChild(link); } } else if (href) { // dns-prefetch preconnect modulepreload search .... globalEnv.rawSetAttribute.call(link, 'href', CompletionPath(href, app.url)); } if (isDynamic) { return { replaceComment }; } else if (replaceComment) { return parent === null || parent === void 0 ? void 0 : parent.replaceChild(replaceComment, link); } } /** * Get link remote resources * @param wrapElement htmlDom * @param app app * @param microAppHead micro-app-head */ function fetchLinksFromHtml(wrapElement, app, microAppHead, fiberStyleResult) { const styleList = Array.from(app.source.links); const fetchLinkPromise = styleList.map((address) => { const linkInfo = sourceCenter.link.getInfo(address); return linkInfo.code ? linkInfo.code : fetchSource(address, app.name); }); const fiberLinkTasks = fiberStyleResult ? [] : null; promiseStream(fetchLinkPromise, (res) => { injectFiberTask(fiberLinkTasks, () => fetchLinkSuccess(styleList[res.index], res.data, microAppHead, app)); }, (err) => { logError(err, app.name); }, () => { /** * 1. If fiberStyleResult exist, fiberLinkTasks must exist * 2. Download link source while processing style * 3. Process style first, and then process link */ if (fiberStyleResult) { fiberStyleResult.then(() => { fiberLinkTasks.push(() => Promise.resolve(app.onLoad({ html: wrapElement }))); serialExecFiberTasks(fiberLinkTasks); }); } else { app.onLoad({ html: wrapElement }); } }); } /** * Fetch link succeeded, replace placeholder with style tag * NOTE: * 1. Only exec when init, no longer exec when remount * 2. Only handler html link element, not dynamic link or style * 3. The same prefix can reuse parsedCode * 4. Async exec with requestIdleCallback in prefetch or fiber * 5. appSpace[app.name].placeholder/attrs must exist * @param address resource address * @param code link source code * @param microAppHead micro-app-head * @param app app instance */ function fetchLinkSuccess(address, code, microAppHead, app) { /** * linkInfo must exist, but linkInfo.code not * so we set code to linkInfo.code */ const linkInfo = sourceCenter.link.getInfo(address); linkInfo.code = code; const appSpaceData = linkInfo.appSpace[app.n