Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 26 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

208 changes: 147 additions & 61 deletions src/single-spa-css.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AppProps, LifeCycleFn } from "single-spa";
import axios from "axios";

const defaultOptions: Required<SingleSpaCssOpts> = {
cssUrls: [],
Expand All @@ -11,6 +12,7 @@ const defaultOptions: Required<SingleSpaCssOpts> = {
linkEl.rel = "stylesheet";
return linkEl;
},
manifestUrl: "",
};

export default function singleSpaCss<ExtraProps>(
Expand All @@ -30,8 +32,17 @@ export default function singleSpaCss<ExtraProps>(
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(
Expand All @@ -51,74 +62,112 @@ export default function singleSpaCss<ExtraProps>(
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<void>((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<void>((resolve, reject) => {
const [url] = extractUrl(cssUrl);
genPreloadLink2Head(url);
// Don't wait for preload to finish before finishing bootstrap
resolve();
})
)
.concat(
manifestUrl
? new Promise<void>((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<void>((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<void>((resolve, reject) => {
const [url, shouldUnmount] = extractUrl(cssUrl);
genLink2Head(url, shouldUnmount, props, opts, resolve, reject);
})
)
.concat(
manifestUrl
? new Promise<void>((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]);
}
}
})
)
})
: []
)
);
}

Expand Down Expand Up @@ -154,15 +203,52 @@ export default function singleSpaCss<ExtraProps>(
}
}

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 =
Expand Down