diff --git a/README.md b/README.md index 8c971fc..2e4892d 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,15 @@ Lifecycle helpers for loading and unmounting css. [Full Documentation](https://single-spa.js.org/docs/ecosystem-css#single-spa-css) + +manifestUrl 用法 + +```js +const cssLifecycles = singleSpaCss({ + manifestUrl: `http://${host}/manifest.json`, // + webpackExtractedCss: false, +}); +``` + +> manifest 是 webpack-manifest-plugin 打包出的资源表 +> support development & production diff --git a/package.json b/package.json index 2c11f13..98e1e91 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@testing-library/dom": "^7.29.4", "@testing-library/jest-dom": "^5.11.9", "@types/jest": "^26.0.20", + "axios": "^0.24.0", "babel-eslint": "^11.0.0-beta.2", "babel-jest": "^26.6.3", "concurrently": "^5.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8ce098..909edc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,7 @@ specifiers: '@testing-library/jest-dom': ^5.11.9 '@types/jest': ^26.0.20 '@types/webpack-env': ^1.16.0 + axios: ^0.24.0 babel-eslint: ^11.0.0-beta.2 babel-jest: ^26.6.3 concurrently: ^5.3.0 @@ -37,6 +38,7 @@ devDependencies: '@testing-library/dom': 7.29.4 '@testing-library/jest-dom': 5.11.9 '@types/jest': 26.0.20 + axios: 0.24.0 babel-eslint: 11.0.0-beta.2_4040d347f50623727eb9d26c8bacfc4b babel-jest: 26.6.3_@babel+core@7.12.10 concurrently: 5.3.0 @@ -1675,6 +1677,14 @@ packages: resolution: {integrity: sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==} dev: true + /axios/0.24.0: + resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==} + dependencies: + follow-redirects: 1.14.6 + transitivePeerDependencies: + - debug + dev: true + /babel-eslint/11.0.0-beta.2_4040d347f50623727eb9d26c8bacfc4b: resolution: {integrity: sha512-D2tunrOu04XloEdU2XVUminUu25FILlGruZmffqH5OSnLDhCheKNvUoM1ihrexdUvhizlix8bjqRnsss4V/UIQ==} engines: {node: '>=8'} @@ -2645,6 +2655,16 @@ packages: resolution: {integrity: sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==} dev: true + /follow-redirects/1.14.6: + resolution: {integrity: sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: true + /for-in/1.0.2: resolution: {integrity: sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=} engines: {node: '>=0.10.0'} @@ -2675,17 +2695,19 @@ packages: dev: true /fsevents/2.1.3: - resolution: {integrity: sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==} + resolution: {integrity: sha1-+3OHA66NL5/pAMM4Nt3r7ouX8j4=} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] deprecated: '"Please update to latest v2.3 or v2.2"' + requiresBuild: true dev: true optional: true /fsevents/2.3.1: - resolution: {integrity: sha512-YR47Eg4hChJGAB1O3yEAOkGO+rlzutoICGqGo9EZ4lKWokzZRSyIW1QmTzqjtw8MJdj9srP869CuWw/hyzSiBw==} + resolution: {integrity: sha1-sgmrFMYQEmNsiGNQft9/tozFTp8=} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + requiresBuild: true dev: true optional: true @@ -3981,7 +4003,8 @@ packages: dev: true /node-notifier/8.0.1: - resolution: {integrity: sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==} + resolution: {integrity: sha1-+G6Ju8kl8rBoeEsx84Kv3Gyla+E=} + requiresBuild: true dependencies: growly: 1.3.0 is-wsl: 2.2.0 diff --git a/src/single-spa-css.ts b/src/single-spa-css.ts index 0ee2ade..ed258a9 100644 --- a/src/single-spa-css.ts +++ b/src/single-spa-css.ts @@ -1,4 +1,5 @@ import { AppProps, LifeCycleFn } from "single-spa"; +import axios from "axios"; const defaultOptions: Required = { cssUrls: [], @@ -11,6 +12,7 @@ const defaultOptions: Required = { linkEl.rel = "stylesheet"; return linkEl; }, + manifestUrl: "", }; export default function singleSpaCss( @@ -30,8 +32,17 @@ export default function singleSpaCss( if (!Array.isArray(opts.cssUrls)) { throw Error("single-spa-css: cssUrls must be an array"); } + if ( + opts.cssUrls.length > 0 && + opts.cssUrls.filter((url) => url).length === 0 + ) { + throw Error("single-spa-css: cssUrls should not be empty string"); + } const allCssUrls = opts.cssUrls; + + const manifestUrl = opts.manifestUrl; + if (opts.webpackExtractedCss) { if (!__webpack_require__.cssAssets) { throw Error( @@ -51,74 +62,112 @@ export default function singleSpaCss( const linkElements: LinkElements = {}; let linkElementsToUnmount: ElementsToUnmount[] = []; + // 生成 preload 链接并插入到 head + function genPreloadLink2Head(url: string) { + const preloadEl = document.querySelector( + `link[rel="preload"][as="style"][href="${url}"]` + ); + + if (!preloadEl) { + const linkEl = document.createElement("link"); + linkEl.rel = "preload"; + linkEl.setAttribute("as", "style"); + linkEl.href = url; + + document.head.appendChild(linkEl); + } + } + function bootstrap(props: AppProps) { return Promise.all( - allCssUrls.map( - (cssUrl) => - new Promise((resolve, reject) => { - const [url] = extractUrl(cssUrl); - const preloadEl = document.querySelector( - `link[rel="preload"][as="style"][href="${url}"]` - ); - - if (!preloadEl) { - const linkEl = document.createElement("link"); - linkEl.rel = "preload"; - linkEl.setAttribute("as", "style"); - linkEl.href = url; - - document.head.appendChild(linkEl); - } - - // Don't wait for preload to finish before finishing bootstrap - resolve(); - }) - ) + allCssUrls + .map( + (cssUrl) => + new Promise((resolve, reject) => { + const [url] = extractUrl(cssUrl); + genPreloadLink2Head(url); + // Don't wait for preload to finish before finishing bootstrap + resolve(); + }) + ) + .concat( + manifestUrl + ? new Promise((resolve) => { + extractManifestCssUrl(manifestUrl).then(([url]) => { + genPreloadLink2Head(url); + }); + // Don't wait for preload to finish before finishing bootstrap + resolve(); + }) + : [] + ) ); } - function mount(props: AppProps) { - return Promise.all( - allCssUrls.map( - (cssUrl) => - new Promise((resolve, reject) => { - const [url, shouldUnmount] = extractUrl(cssUrl); + function genLink2Head(url, shouldUnmount, props, opts, resolve, reject) { + const existingLinkEl = document.querySelector( + `link[rel="stylesheet"][href="${url}"]` + ); - const existingLinkEl = document.querySelector( - `link[rel="stylesheet"][href="${url}"]` - ); + if (existingLinkEl) { + linkElements[url] = existingLinkEl as HTMLLinkElement; + resolve(); + } else { + const timeout = setTimeout(() => { + reject( + `single-spa-css: While mounting '${props.name}', loading CSS from URL ${linkEl.href} timed out after ${opts.timeout}ms` + ); + }, opts.timeout); + const linkEl = opts.createLink(url); + linkEl.addEventListener("load", () => { + clearTimeout(timeout); + resolve(); + }); + linkEl.addEventListener("error", () => { + clearTimeout(timeout); + reject( + Error( + `single-spa-css: While mounting '${props.name}', loading CSS from URL ${linkEl.href} failed.` + ) + ); + }); + linkElements[url] = linkEl; + document.head.appendChild(linkEl); - if (existingLinkEl) { - linkElements[url] = existingLinkEl as HTMLLinkElement; - resolve(); - } else { - const timeout = setTimeout(() => { - reject( - `single-spa-css: While mounting '${props.name}', loading CSS from URL ${linkEl.href} timed out after ${opts.timeout}ms` - ); - }, opts.timeout); - const linkEl = opts.createLink(url); - linkEl.addEventListener("load", () => { - clearTimeout(timeout); - resolve(); - }); - linkEl.addEventListener("error", () => { - clearTimeout(timeout); - reject( - Error( - `single-spa-css: While mounting '${props.name}', loading CSS from URL ${linkEl.href} failed.` - ) + if (shouldUnmount) { + linkElementsToUnmount.push([linkEl, url]); + } + } + } + + function mount(props: AppProps) { + return Promise.all( + allCssUrls + .map( + (cssUrl) => + new Promise((resolve, reject) => { + const [url, shouldUnmount] = extractUrl(cssUrl); + genLink2Head(url, shouldUnmount, props, opts, resolve, reject); + }) + ) + .concat( + manifestUrl + ? new Promise((resolve, reject) => { + extractManifestCssUrl(manifestUrl).then( + ([url, shouldUnmount]) => { + genLink2Head( + url, + shouldUnmount, + props, + opts, + resolve, + reject + ); + } ); - }); - linkElements[url] = linkEl; - document.head.appendChild(linkEl); - - if (shouldUnmount) { - linkElementsToUnmount.push([linkEl, url]); - } - } - }) - ) + }) + : [] + ) ); } @@ -154,15 +203,52 @@ export default function singleSpaCss( } } + function extractManifestCssUrl(jsonUrl: string): Promise<[string, boolean]> { + // const origin = /^(http|https)?:\/\/[\w-.]+(:\d+)?/i.exec(jsonUrl)![0]; + let linkArr; + let url = ""; + linkArr = jsonUrl.split("/"); + linkArr.pop(); + return new Promise((resolve, reject) => { + axios.get(jsonUrl).then((res) => { + // 这里可能存在多种格式的 manifest.json 文件,目前支持两种格式 + /** + * { + * "main.css": "main.f340417f.css", + * "main.js": "spa-app-main-app.js" + * } + */ + /** + * {"entrypoints": [ + "static/css/main.af5826ae.css", + "main.2df2e8a2.js" + ]} + */ + if (res.data["main.css"]) { + linkArr.push(res.data["main.css"]); + } + if (res.data["entrypoints"]) { + linkArr.push(res.data["entrypoints"][0]); + } + url = linkArr.join("/"); + resolve([ + url, + opts.shouldUnmount, // 使用默认的 + ]); + }); + }); + } + return { bootstrap, mount, unmount }; } type SingleSpaCssOpts = { - cssUrls: CssUrl[]; + cssUrls?: CssUrl[]; webpackExtractedCss?: boolean; timeout?: number; shouldUnmount?: boolean; createLink?: (url: string) => HTMLLinkElement; + manifestUrl?: string; }; type CssUrl =