diff --git a/package.json b/package.json index 243ef032..4931a8d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ice/stark", - "version": "1.5.2", + "version": "1.5.3", "description": "Icestark is a JavaScript library for multiple projects, Ice workbench solution.", "scripts": { "build": "rm -rf lib && tsc", @@ -38,7 +38,7 @@ "react": ">=15.0.0" }, "dependencies": { - "@ice/sandbox": "^1.0.3", + "@ice/sandbox": "^1.0.4", "lodash.isequal": "^4.5.0", "path-to-regexp": "^1.7.0", "url-parse": "^1.1.9" diff --git a/packages/icestark-module/package.json b/packages/icestark-module/package.json index c8c7be17..945e2a80 100644 --- a/packages/icestark-module/package.json +++ b/packages/icestark-module/package.json @@ -1,6 +1,6 @@ { "name": "@ice/stark-module", - "version": "1.0.1", + "version": "1.1.0", "description": "toolkit for load standard micro-module", "main": "lib/index.js", "scripts": { diff --git a/packages/icestark-module/src/loader.ts b/packages/icestark-module/src/loader.ts index 7ac5721e..a2b70f1b 100644 --- a/packages/icestark-module/src/loader.ts +++ b/packages/icestark-module/src/loader.ts @@ -3,13 +3,13 @@ import { getGlobalProp, noteGlobalProps } from './global'; export interface StarkModule { name: string; - url: string; + url: string|string[]; mount?: (Component: any, targetNode: HTMLElement, props?: any) => void; unmount?: (targetNode: HTMLElement) => void; }; export interface ImportTask { - [name: string]: Promise; + [name: string]: Promise; }; export type PromiseModule = Promise; @@ -21,13 +21,15 @@ export interface Fetch { export default class ModuleLoader { private importTask: ImportTask = {}; - load(starkModule: StarkModule, fetch: Fetch = window.fetch): Promise { + load(starkModule: StarkModule, fetch: Fetch = window.fetch): Promise { const { url, name } = starkModule; if (this.importTask[name]) { // return promise if current module is pending or resolved return this.importTask[name]; } - const task = fetch(url).then((res) => res.text()); + const urls = Array.isArray(url) ? url : [url]; + + const task = Promise.all(urls.map((scriptUrl) => fetch(scriptUrl).then((res) => res.text()))); this.importTask[name] = task; return task; } @@ -37,7 +39,7 @@ export default class ModuleLoader { } execModule(starkModule: StarkModule, sandbox?: Sandbox) { - return this.load(starkModule).then((source) => { + return this.load(starkModule).then((sources) => { let globalWindow = null; if (sandbox?.getSandbox) { sandbox.createProxySandbox(); @@ -45,19 +47,32 @@ export default class ModuleLoader { } else { globalWindow = window; } - noteGlobalProps(globalWindow); - // check sandbox - if (sandbox?.execScriptInSandbox) { - sandbox.execScriptInSandbox(source); - } else { - // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/eval - // eslint-disable-next-line no-eval - (0, eval)(source); - } const { name } = starkModule; - const libraryExport = getGlobalProp(globalWindow); - - return (globalWindow as any)[name] || (globalWindow as any)[libraryExport] || {}; + let libraryExport = ''; + // excute script in order + sources.forEach((source, index) => { + const lastScript = index === sources.length - 1; + if (lastScript) { + noteGlobalProps(globalWindow); + } + // check sandbox + if (sandbox?.execScriptInSandbox) { + sandbox.execScriptInSandbox(source); + } else { + // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/eval + // eslint-disable-next-line no-eval + (0, eval)(source); + } + if (lastScript) { + libraryExport = getGlobalProp(globalWindow); + } + }); + const moduleInfo = libraryExport ? (globalWindow as any)[libraryExport] : ((globalWindow as any)[name] || {}); + // remove moduleInfo from globalWindow in case of excute multi module in globalWindow + if ((globalWindow as any)[libraryExport]) { + delete globalWindow[libraryExport]; + } + return moduleInfo; }); } }; \ No newline at end of file diff --git a/packages/icestark-module/src/modules.tsx b/packages/icestark-module/src/modules.tsx index 4a5d8ac5..689d3623 100644 --- a/packages/icestark-module/src/modules.tsx +++ b/packages/icestark-module/src/modules.tsx @@ -8,6 +8,7 @@ type ISandbox = boolean | SandboxProps | SandboxContructor; let globalModules = []; let importModules = {}; +const IS_CSS_REGEX = /\.css(\?((?!\.js$).)+)?$/; export const moduleLoader = new ModuleLoader(); export const registerModules = (modules: StarkModule[]) => { @@ -38,10 +39,7 @@ export function renderComponent(Component: any, props = {}): React.ReactElement */ const defaultMount = (Component: any, targetNode: HTMLElement, props?: any) => { console.warn('Please set mount, try run react mount function'); - try { - ReactDOM.render(renderComponent(Component, props), targetNode); - // eslint-disable-next-line no-empty - } catch(err) {} + ReactDOM.render(renderComponent(Component, props), targetNode); }; /** @@ -49,10 +47,7 @@ const defaultMount = (Component: any, targetNode: HTMLElement, props?: any) => { */ const defaultUnmount = (targetNode: HTMLElement) => { console.warn('Please set unmount, try run react unmount function'); - try { - ReactDOM.unmountComponentAtNode(targetNode); - // eslint-disable-next-line no-empty - } catch(err) {} + ReactDOM.unmountComponentAtNode(targetNode); }; function createSandbox(sandbox: ISandbox) { @@ -69,6 +64,65 @@ function createSandbox(sandbox: ISandbox) { return moduleSandbox; } +/** + * parse url assets + */ +export const parseUrlAssets = (assets: string | string[]) => { + const jsList = []; + const cssList = []; + (Array.isArray(assets) ? assets : [assets]).forEach(url => { + const isCss: boolean = IS_CSS_REGEX.test(url); + if (isCss) { + cssList.push(url); + } else { + jsList.push(url); + } + }); + + return { jsList, cssList }; +}; + + +export function appendCSS( + name: string, + url: string, + root: HTMLElement | ShadowRoot = document.getElementsByTagName('head')[0], +): Promise { + return new Promise((resolve, reject) => { + if (!root) reject(new Error(`no root element for css assert: ${url}`)); + + const element: HTMLLinkElement = document.createElement('link'); + element.setAttribute('module', name); + element.rel = 'stylesheet'; + element.href = url; + + element.addEventListener( + 'error', + () => { + console.error(`css asset loaded error: ${url}`); + return resolve(); + }, + false, + ); + element.addEventListener('load', () => resolve(), false); + + root.appendChild(element); + }); +} + +/** + * remove css + */ + +export function removeCSS(name: string, node?: HTMLElement | Document) { + const linkList: NodeListOf = (node || document).querySelectorAll( + `link[module=${name}]`, + ); + linkList.forEach(link => { + link.parentNode.removeChild(link); + }); +} + /** * return globalModules */ @@ -77,30 +131,50 @@ export const getModules = function () { }; /** - * mount module function + * load module source */ -export const mountModule = async (targetModule: StarkModule, targetNode: HTMLElement, props: any = {}, sandbox?: ISandbox) => { - const { name } = targetModule; + +export const loadModule = async(targetModule: StarkModule, sandbox?: ISandbox) => { + const { name, url } = targetModule; let moduleSandbox = null; if (!importModules[name]) { + const { jsList, cssList } = parseUrlAssets(url); moduleSandbox = createSandbox(sandbox); - const moduleInfo = await moduleLoader.execModule(targetModule, moduleSandbox); + const moduleInfo = await moduleLoader.execModule({ name, url: jsList }, moduleSandbox); importModules[name] = { moduleInfo, moduleSandbox, + moduleCSS: cssList, }; } - const moduleInfo = importModules[name].moduleInfo; + const { moduleInfo, moduleCSS } = importModules[name]; if (!moduleInfo) { - console.error('load or exec module faild'); - return; + const errMsg = 'load or exec module faild'; + console.error(errMsg); + return Promise.reject(new Error(errMsg)); } const mount = targetModule.mount || moduleInfo?.mount || defaultMount; const component = moduleInfo.default || moduleInfo; + // append css before mount module + if (moduleCSS.length) { + await Promise.all(moduleCSS.map((css: string) => appendCSS(name, css))); + } + + return { + mount, + component, + }; +}; + +/** + * mount module function + */ +export const mountModule = async (targetModule: StarkModule, targetNode: HTMLElement, props: any = {}, sandbox?: ISandbox) => { + const { mount, component } = await loadModule(targetModule, sandbox); return mount(component, targetNode, props); }; @@ -112,7 +186,7 @@ export const unmoutModule = (targetModule: StarkModule, targetNode: HTMLElement) const moduleInfo = importModules[name]?.module; const moduleSandbox = importModules[name]?.moduleSandbox; const unmount = targetModule.unmount || moduleInfo?.unmount || defaultUnmount; - + removeCSS(name); if (moduleSandbox?.clear) { moduleSandbox.clear(); } @@ -123,11 +197,25 @@ export const unmoutModule = (targetModule: StarkModule, targetNode: HTMLElement) /** * default render compoent, mount all modules */ -export class MicroModule extends React.Component { +export class MicroModule extends React.Component { private moduleInfo = null; private mountNode = null; + private unmout = false; + + static defaultProps = { + loadingComponent: null, + handleError: () => {}, + }; + + constructor(props) { + super(props); + this.state = { + loading: false, + }; + } + componentDidMount() { this.mountModule(); } @@ -140,23 +228,39 @@ export class MicroModule extends React.Component { componentWillUnmount() { unmoutModule(this.moduleInfo, this.mountNode); + this.unmout = true; } - mountModule() { + async mountModule() { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { sandbox, moduleInfo, wrapperClassName, wrapperStyle, ...rest } = this.props; + const { sandbox, moduleInfo, wrapperClassName, wrapperStyle, loadingComponent, handleError, ...rest } = this.props; this.moduleInfo = moduleInfo || getModules().filter(m => m.name === this.props.moduleName)[0]; if (!this.moduleInfo) { console.error(`Can't find ${this.props.moduleName} module in modules config`); return; } - - mountModule(this.moduleInfo, this.mountNode, rest, sandbox); + this.setState({ loading: true }); + try { + const { mount, component } = await loadModule(this.moduleInfo, sandbox); + this.setState({ loading: false }); + if (mount && component) { + if (this.unmout) { + unmoutModule(this.moduleInfo, this.mountNode); + } else { + mount(component, this.mountNode, rest); + } + } + } catch (err) { + this.setState({ loading: false }); + handleError(err); + } } render() { - const { wrapperClassName, wrapperStyle } = this.props; - return (
this.mountNode = ref} />); + const { loading } = this.state; + const { wrapperClassName, wrapperStyle, loadingComponent } = this.props; + return loading ? loadingComponent + :
this.mountNode = ref} />; } }; diff --git a/packages/icestark-module/tests/index.spec.tsx b/packages/icestark-module/tests/index.spec.tsx index c062abc4..fe16a897 100644 --- a/packages/icestark-module/tests/index.spec.tsx +++ b/packages/icestark-module/tests/index.spec.tsx @@ -6,11 +6,23 @@ import * as fs from 'fs'; import * as path from 'path'; import Sandbox, { SandboxContructor } from '@ice/sandbox'; -import renderModules, { getModules, MicroModule, mountModule, unmoutModule } from '../src/modules'; +import renderModules, { + getModules, + parseUrlAssets, + appendCSS, + clearModules, + MicroModule, + mountModule, + unmoutModule, + removeCSS, +} from '../src/modules'; const modules = [{ name: 'selfComponent', url: 'http://127.0.0.1:3334/index.js', +}, { + name: 'error', + url: 'http://127.0.0.1:3334/error.js', }]; declare global { @@ -27,9 +39,9 @@ window.ReactDOM = ReactDOM; describe('render modules', () => { beforeEach(() => { const source = fs.readFileSync(path.resolve(__dirname, './component.js')); - window.fetch = () => { + window.fetch = (url) => { return Promise.resolve({ - text: () => source.toString(), + text: url.indexOf('error') === -1 ? () => source.toString() : () => 'const error = 1;', }); }; }); @@ -64,7 +76,16 @@ describe('render modules', () => { }, 0); }); - test('render MicroModule with default sanbox', (next) => { + test('render loadingComponent', (next) => { + const { container } = render(loading
} />); + expect(container.innerHTML).toBe('
loading
'); + setTimeout(() => { + expect(container.innerHTML).toBe('

404

'); + next(); + }, 0); + }); + + test('render MicroModule with default sandbox', (next) => { const { container } = render(); setTimeout(() => { expect(container.innerHTML).toBe('

404

'); @@ -78,7 +99,7 @@ describe('render modules', () => { expect(container.innerHTML).toBe('

404

'); next(); }, 0); - }); + }); test('mountModule with default sandbox', (next) => { const moduleInfo = { name: 'defaultSandbox', url: '//localhost' }; @@ -103,4 +124,50 @@ describe('render modules', () => { next(); }, 0); }); + + test('load error module', (next) => { + const { container } = render( { + expect(true).toBe(true); + next(); + }} />); + try { + const moduleInfo = modules.find(({ name }) => name === 'error'); + unmoutModule(moduleInfo, container); + expect(false).toBe(true); + } catch(error) { + expect(true).toBe(true); + } + }); + + test('append css', () => { + const container = document.createElement('div'); + appendCSS('css', 'http://test.css', container); + expect(container.innerHTML).toBe(''); + removeCSS('css', container); + expect(container.innerHTML).toBe(''); + }); + + test('parse url assets', () => { + const assets = parseUrlAssets([ + '//icestark.com/index.css', + '//icestark.com/index.css?timeSamp=1575443657834', + '//icestark.com/index.js', + '//icestark.com/index.js?timeSamp=1575443657834', + ]); + expect(assets).toStrictEqual({ + cssList: [ + '//icestark.com/index.css', + '//icestark.com/index.css?timeSamp=1575443657834', + ], + jsList: [ + '//icestark.com/index.js', + '//icestark.com/index.js?timeSamp=1575443657834', + ], + }); + }) + + test('clear module', () => { + clearModules(); + expect(getModules()).toStrictEqual([]); + }); }); \ No newline at end of file diff --git a/packages/icestark-module/tests/loader.spec.ts b/packages/icestark-module/tests/loader.spec.ts index 535f1b85..564d63e7 100644 --- a/packages/icestark-module/tests/loader.spec.ts +++ b/packages/icestark-module/tests/loader.spec.ts @@ -22,28 +22,25 @@ describe('module loader', () => { }); const moduleLoader = new ModuleLoader(); - test('load module', () => { + test('load module', async () => { const task = moduleLoader.load({ url: '//localhost', name: 'test', }); - task.then((text) => { - expect(text).toEqual('//localhost'); - }); + const res = await task; + expect(res).toEqual(['//localhost']); }); - test('load cache', () => { + test('load cache', async () => { const task = moduleLoader.load({ name: 'test', url: '//localhost2' }); - task.then((text) => { - expect(text).toEqual('//localhost'); - }); + const res = await task; + expect(res).toEqual(['//localhost']); }); - test('load source', () => { + test('load source', async () => { const task = moduleLoader.load({ name: 'testsource', url: '//source' }); - task.then((text) => { - expect(text).toEqual(source.toString()); - }); + const res = await task; + expect(res).toEqual([source.toString()]); }); test('execute module', async () => { diff --git a/packages/icestark-sandbox/package.json b/packages/icestark-sandbox/package.json index 5e44abe8..ea10d880 100644 --- a/packages/icestark-sandbox/package.json +++ b/packages/icestark-sandbox/package.json @@ -1,6 +1,6 @@ { "name": "@ice/sandbox", - "version": "1.0.3", + "version": "1.0.4", "description": "sandbox for execute scripts", "main": "lib/index.js", "scripts": { diff --git a/packages/icestark-sandbox/src/index.ts b/packages/icestark-sandbox/src/index.ts index 44a9f792..3a872159 100644 --- a/packages/icestark-sandbox/src/index.ts +++ b/packages/icestark-sandbox/src/index.ts @@ -114,6 +114,11 @@ export default class Sandbox { if (['top', 'window', 'self', 'globalThis'].includes(p as string)) { return sandbox; } + // proxy hasOwnProperty, in case of proxy.hasOwnProperty value represented as originalWindow.hasOwnProperty + if (p === 'hasOwnProperty') { + // eslint-disable-next-line no-prototype-builtins + return (key: PropertyKey) => !!target[key] || originalWindow.hasOwnProperty(key); + } const targetValue = target[p]; if (targetValue) { // case of addEventListener, removeEventListener, setTimeout, setInterval setted in sandbox diff --git a/src/AppRoute.tsx b/src/AppRoute.tsx index 2f9a7f77..19db0cd9 100644 --- a/src/AppRoute.tsx +++ b/src/AppRoute.tsx @@ -4,7 +4,7 @@ import { AppHistory } from './appHistory'; import renderComponent from './util/renderComponent'; import { appendAssets, emptyAssets, cacheAssets, getEntryAssets, getUrlAssets } from './util/handleAssets'; import { setCache, getCache } from './util/cache'; -import { callAppEnter, callAppLeave, cacheApp, isCached } from './util/appLifeCycle'; +import { callAppEnter, callAppLeave, cacheApp, isCached, AppLifeCycleEnum } from './util/appLifeCycle'; import { callCapturedEventListeners } from './util/capturedListeners'; import isEqual = require('lodash.isequal'); @@ -38,6 +38,13 @@ export interface AppRouteComponentProps void; } -export function converArray2String(list: string | string[]) { +export function converArray2String(list: string | (string | PathData)[]) { if (Array.isArray(list)) { - return list.join(','); + return list.map((item) => { + if (Object.prototype.toString.call(item) === '[object Object]') { + return Object.keys(item).map((key) => `${key}:${item[key]}`).join(','); + } + return item; + }).join(','); } return String(list); @@ -227,6 +239,7 @@ export default class AppRoute extends React.Component `${addLeadingSlash(appBasename)}${(pathStr as PathData).value || pathStr}`) + : path; if (hashType) { const decodePath = HashPathDecoders[hashType === true ? 'slash' : hashType]; const hashPath = decodePath(getHashPath(hash)); @@ -310,7 +312,7 @@ export default class AppRouter extends React.Component { if (!path) return null; if (matched) return matched; - - const { regexp, keys } = compilePath(path, { + const { value, ...restOptions } = Object.prototype.toString.call(path) === '[object Object]' + ? path + : ({} as unknown); + const pathValue = value || path; + const pathOptions = { end: exact, strict, sensitive, - }); + ...restOptions, + }; + if (pathOptions.exact) { + // overwrite exact value to end + pathOptions.end = pathOptions.exact; + delete pathOptions.exact; + } + const { regexp, keys } = compilePath(pathValue, pathOptions); const match = regexp.exec(pathname); if (!match) return null; @@ -51,7 +61,7 @@ export default function matchPath(pathname: string, options: any = {}) { if (exact && !isExact) return null; return { - path, // the path used to match + path: pathValue, // the path used to match url: path === '/' && url === '' ? '/' : url, // the matched portion of the URL isExact, // whether or not we matched exactly params: keys.reduce((memo, key, index) => { diff --git a/tests/matchPath.spec.tsx b/tests/matchPath.spec.tsx index d62ea589..39234185 100644 --- a/tests/matchPath.spec.tsx +++ b/tests/matchPath.spec.tsx @@ -23,5 +23,11 @@ describe('matchPath', () => { match = matchPath('/test/123', { path: '/test', exact: true }); expect(match).toBeNull(); + + match = matchPath('/', { path: [{ value: '/', exact: true }, { value: '/test' }] }); + expect(match.url).toBe('/'); + + match = matchPath('/test/123', { path: [{ value: '/', exact: true }, { value: '/test' }] }); + expect(match.url).toBe('/test'); }); });