From c8e6b320a979008f792dbfec6104c7f74285c7e8 Mon Sep 17 00:00:00 2001 From: TechQuery Date: Tue, 22 Oct 2019 11:56:52 +0800 Subject: [PATCH] [refactor] Core Logic --- ReadMe.md | 18 +++++++- package.json | 4 +- source/HTMLRouter.ts | 74 +++++++++++++++++++++-------- source/History.ts | 77 ++++++++++++++++--------------- source/utility.ts | 29 ------------ source/utility.tsx | 43 +++++++++++++++++ test/DOM-polyfill.ts | 7 ++- test/index.spec.ts | 37 ++++++++++----- test/nested.spec.ts | 72 ++++++++--------------------- test/source/model/index.ts | 4 +- test/source/page/NestedRouter.tsx | 42 +++++++++-------- test/source/page/SimpleRouter.tsx | 34 +++++++++++--- test/source/page/index.tsx | 6 +-- test/utility.spec.ts | 18 ++------ tsconfig.json | 4 +- 15 files changed, 269 insertions(+), 200 deletions(-) delete mode 100644 source/utility.ts create mode 100644 source/utility.tsx diff --git a/ReadMe.md b/ReadMe.md index 04967f3..47f8b75 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -58,6 +58,14 @@ import { HTMLRouter } from 'cell-router/source'; import { history } from '../model'; +function Test({ path }) { + return {path}; +} + +function Example({ path }) { + return {path}; +} + @observer @component({ tagName: 'page-router', @@ -77,7 +85,15 @@ export default class PageRouter extends HTMLRouter { Example -
{history.path}
+
+ {matchRoutes( + [ + { paths: ['test'], component: Test }, + { paths: ['example'], component: Example } + ], + history.path + )} +
); } diff --git a/package.json b/package.json index f7c3044..6a10836 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cell-router", - "version": "2.0.0-beta.6", + "version": "2.0.0-rc.0", "license": "LGPL-3.0", "description": "Web Component Router based on WebCell & MobX", "keywords": [ @@ -39,7 +39,7 @@ "koapache": "^1.0.6", "lint-staged": "^9.4.2", "microbundle": "^0.11.0", - "mobx": "^5.14.0", + "mobx": "^5.14.2", "mobx-web-cell": "^0.2.3", "parcel-bundler": "^1.12.4", "prettier": "^1.18.2", diff --git a/source/HTMLRouter.ts b/source/HTMLRouter.ts index 501f253..c34b54b 100644 --- a/source/HTMLRouter.ts +++ b/source/HTMLRouter.ts @@ -2,31 +2,69 @@ import { mixin, delegate } from 'web-cell'; import { History } from './History'; -const NonRoute = /^((\w+:)?\/\/|#|javascript:)/; +type LinkElement = HTMLAnchorElement | HTMLAreaElement; export abstract class HTMLRouter extends mixin() { - protected abstract history: History; - - constructor() { - super(); + static isRoute(link: LinkElement) { + const path = link.getAttribute('href'); - this.addEventListener( - 'click', - delegate('a[href], area[href]', this.handleLink) + return ( + (link.target || '_self') === '_self' && + /^https?:$/.test(link.protocol) && + path !== link.href && + path[0] !== '#' ); } - handleLink = ( - event: MouseEvent, - link: HTMLAnchorElement | HTMLAreaElement - ) => { - if ((link.target || '_self') !== '_self') return; + get parentRouter(): HTMLRouter | undefined { + var node = this; + // @ts-ignore + while ((node = node.parentNode || node.host)) + if (node instanceof HTMLRouter) return node; + } - event.preventDefault(), event.stopPropagation(); + protected abstract history: History; - const path = link.getAttribute('href'); + push = delegate('a[href]', (event: MouseEvent) => { + const link = event.target as LinkElement; + + if (HTMLRouter.isRoute(link)) { + event.preventDefault(), event.stopPropagation(); + + this.history.push( + link.getAttribute('href'), + link.title || link.textContent + ); + } else if (/^#.+/.test(link.getAttribute('href'))) { + const anchor = this.querySelector(link.hash); + + if (anchor) anchor.scrollIntoView({ behavior: 'smooth' }); + } + }); - if (path && !NonRoute.test(path)) - this.history.push(path, link.title || link.textContent.trim()); - }; + back = () => this.history.back(); + + connectedCallback() { + super.connectedCallback(); + + const { hash, href } = window.location; + + if (!this.parentRouter) { + this.history.base = hash ? href.slice(0, -hash.length) : href; + + const { base, path, title, ...data } = history.state || {}; + + this.history.replace(hash.slice(1), title, data); + } else { + this.history.base = href; + } + + this.addEventListener('click', this.push); + window.addEventListener('popstate', this.back); + } + + disconnectedCallback() { + this.removeEventListener('click', this.push); + window.removeEventListener('popstate', this.back); + } } diff --git a/source/History.ts b/source/History.ts index 018afcb..59b0484 100644 --- a/source/History.ts +++ b/source/History.ts @@ -1,5 +1,4 @@ -import { observable } from 'mobx'; -import { parseURL } from './utility'; +import { observable, action } from 'mobx'; const { location, history } = window; @@ -9,58 +8,60 @@ export enum HistoryMode { } export class History { - protected mode: HistoryMode; - protected root = location.pathname; + protected baseURL: string; - @observable - path: string; - - constructor(mode: HistoryMode = HistoryMode.hash) { - this.mode = mode; - this.path = - mode === HistoryMode.path ? '' : location.hash.split('#')[1]; + set base(value: string) { + const { origin, pathname, hash } = new URL(value, location.href); - var { title } = history.state || ''; - - if (title) document.title = title; - else title = document.title; + this.baseURL = origin + pathname + hash; + } - history.replaceState( - { mode, root: this.root, path: this.path, title }, - title - ); + get base() { + return this.baseURL; + } - window.addEventListener('popstate', ({ state }) => { - if (!state) return; + @observable + path = ''; - const { mode, root, path, title } = state; + mode: HistoryMode; - if (mode !== this.mode || root !== this.root) return; + constructor(mode = HistoryMode.hash) { + this.mode = mode; + } - if (title) document.title = title; + @action + push(path: string, title = document.title, data?: any) { + const { base, mode } = this; - this.path = path; - }); - } + history.pushState( + { ...data, base, path, title }, + (document.title = title), + base + mode + path + ); - mount(path: string = location.pathname + location.hash) { - (this.root = path), (this.path = ''); + this.path = path; } - push(path: string, title: string, data?: any) { - if (title) document.title = title; - else title = document.title; + @action + replace(path: string, title = document.title, data?: any) { + const { base, mode } = this; - history.pushState( - { ...data, mode: this.mode, root: this.root, path, title }, - title, - (this.root + this.mode + path).replace(/\/{2,}/g, '/') + history.replaceState( + { ...data, base, path, title }, + (document.title = title), + base + mode + path ); this.path = path; } - get parsedPath() { - return parseURL(this.path); + @action + back() { + const { base, path, title } = history.state || {}; + + if (base === this.base) { + if (typeof path === 'string') this.path = path; + if (title) document.title = title; + } } } diff --git a/source/utility.ts b/source/utility.ts deleted file mode 100644 index 232852a..0000000 --- a/source/utility.ts +++ /dev/null @@ -1,29 +0,0 @@ -export function parseURL(path: string) { - const data = {}, - searchParams = {}, - URI = new URL(path, window.location.href); - - for (const key in URI) - if (typeof URI[key] !== 'function') data[key] = URI[key]; - // @ts-ignore - for (let [key, value] of Array.from(URI.searchParams.entries())) { - const item = searchParams[key]; - - try { - value = JSON.parse(value); - } catch (error) { - /**/ - } - - if (!(item != null)) { - searchParams[key] = value; - continue; - } - - if (!(item instanceof Array)) searchParams[key] = [item]; - - searchParams[key].push(value); - } - - return { ...data, searchParams }; -} diff --git a/source/utility.tsx b/source/utility.tsx new file mode 100644 index 0000000..932d33d --- /dev/null +++ b/source/utility.tsx @@ -0,0 +1,43 @@ +import { createCell } from 'web-cell'; + +export function parsePathData(URI: string) { + const [path, data] = URI.split('?'), + params = {}; + // @ts-ignore + for (let [key, value] of Array.from(new URLSearchParams(data).entries())) { + const item = params[key]; + + try { + value = JSON.parse(value); + } catch (error) { + /**/ + } + + if (!(item != null)) { + params[key] = value; + continue; + } + + if (!(item instanceof Array)) params[key] = [item]; + + params[key].push(value); + } + + return { path, params }; +} + +interface Route { + paths: (string | RegExp)[]; + component: Function; +} + +export function matchRoutes(list: Route[], path: string) { + for (const { paths, component: Component } of list) + for (const item of paths) + if ( + typeof item === 'string' + ? path.startsWith(item) + : item.exec(path) + ) + return ; +} diff --git a/test/DOM-polyfill.ts b/test/DOM-polyfill.ts index 700c9e3..844bbdf 100644 --- a/test/DOM-polyfill.ts +++ b/test/DOM-polyfill.ts @@ -1,8 +1,11 @@ import { JSDOM } from 'jsdom'; -const { window } = new JSDOM('', { url: 'http://localhost/' }); +const { window } = new JSDOM('', { + url: 'http://localhost/', + pretendToBeVisual: true +}); -for (const key of ['window', 'URL']) { +for (const key of ['window', 'document', 'URL']) { // @ts-ignore global[key] = window[key]; } diff --git a/test/index.spec.ts b/test/index.spec.ts index 6125992..0a9139e 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -3,7 +3,7 @@ import { bootServer, getPage, expectPage } from './browser'; var server: string, page: Page; -describe('Simple router', () => { +describe('Top router', () => { beforeAll(async () => { server = await bootServer(); @@ -11,42 +11,57 @@ describe('Simple router', () => { }); it('should render Router component', async () => { - expect(await page.$eval('simple-router', tag => tag.innerHTML)).toBe( + expect(await page.$eval('top-router', tag => tag.innerHTML)).toBe( '
' ); }); it('should turn to a page after clicking a link', async () => { - await page.click('simple-router li:first-child a'); + await page.click('top-router li:first-child a'); - await expectPage('simple-router', 'test', 'Test', '#test'); + await expectPage('top-router', 'test', 'Test', '#test'); - await page.click('simple-router li:last-child a'); + await page.click('top-router li:last-child a'); - await expectPage('simple-router', 'example', 'Example', '#example'); + await expectPage( + 'top-router', + 'exampleSampleTemp', + 'Example', + '#example' + ); }); it('should turn to a page after navigating', async () => { await page.goBack(); - await expectPage('simple-router', 'test', 'Test', '#test'); + await expectPage('top-router', 'test', 'Test', '#test'); await page.goForward(); - await expectPage('simple-router', 'example', 'Example', '#example'); + await expectPage( + 'top-router', + 'exampleSampleTemp', + 'Example', + '#example' + ); }); it('should render a page based on Router path after reloading', async () => { await page.reload(); - await expectPage('simple-router', 'example', 'Example', '#example'); + await expectPage( + 'top-router', + 'exampleSampleTemp', + 'Example', + '#example' + ); await page.goBack(); - await expectPage('simple-router', 'test', 'Test', '#test'); + await expectPage('top-router', 'test', 'Test', '#test'); await page.goBack(); - await expectPage('simple-router', '', 'Cell Router', ''); + await expectPage('top-router', '', 'Cell Router', ''); }); }); diff --git a/test/nested.spec.ts b/test/nested.spec.ts index 53b3dc8..8728832 100644 --- a/test/nested.spec.ts +++ b/test/nested.spec.ts @@ -3,92 +3,56 @@ import { bootServer, getPage, expectPage } from './browser'; var server: string, page: Page; -describe('Simple router', () => { +describe('Sample router', () => { beforeAll(async () => { server = await bootServer(); page = await getPage(server); }); - it('should render Router component', async () => { - expect(await page.$eval('nested-router', tag => tag.innerHTML)).toBe( - '
' - ); - }); - - it('should turn to a page after clicking a link', async () => { - await page.click('nested-router li:first-child a'); + it('should render Sub Router component', async () => { + await page.click('top-router li:last-child a'); - await expectPage('nested-router', 'simple', 'Simple', '#simple'); - - await page.click('nested-router li:last-child a'); - - await expectPage( - 'nested-router simple-router', - '', - 'Nested', - '#nested' + expect(await page.$eval('sub-router', tag => tag.innerHTML)).toBe( + '
' ); }); it('should turn to a Sub page after clicking a Nested link', async () => { - await page.click('nested-router simple-router li:first-child a'); + await page.click('sub-router li:first-child a'); - await expectPage( - 'nested-router simple-router', - 'test', - 'Test', - '#nested#test' - ); + await expectPage('sub-router', 'sample', 'Sample', '#example#sample'); - await page.click('nested-router simple-router li:last-child a'); + await page.click('sub-router li:last-child a'); - await expectPage( - 'nested-router simple-router', - 'example', - 'Example', - '#nested#example' - ); + await expectPage('sub-router', 'temp', 'Temp', '#example#temp'); }); it('should turn to a Sub page after Nested navigating', async () => { await page.goBack(); - await expectPage( - 'nested-router simple-router', - 'test', - 'Test', - '#nested#test' - ); - + await expectPage('sub-router', 'sample', 'Sample', '#example#sample'); + /* await page.goBack(); await page.goBack(); - await expectPage('nested-router', 'simple', 'Simple', '#simple'); + await expectPage('top-router', 'test', 'Test', '#test'); +*/ }); - + /* it('should render a Sub page based on Router path after reloading', async () => { await page.reload(); - await expectPage('nested-router', 'simple', 'Simple', '#simple'); + await expectPage('top-router', 'test', 'Test', '#test'); await page.goForward(); await page.goForward(); - await expectPage( - 'nested-router simple-router', - 'test', - 'Test', - '#nested#test' - ); + await expectPage('sub-router', 'sample', 'Sample', '#example#sample'); await page.goForward(); - await expectPage( - 'nested-router simple-router', - 'example', - 'Example', - '#nested#example' - ); + await expectPage('sub-router', 'temp', 'Temp', '#example#temp'); }); +*/ }); diff --git a/test/source/model/index.ts b/test/source/model/index.ts index 9973b99..a2b4771 100644 --- a/test/source/model/index.ts +++ b/test/source/model/index.ts @@ -1,4 +1,4 @@ import { History } from '../../../source'; -export const simpleHistory = new History(); -export const nestedHistory = new History(); +export const topHistory = new History(); +export const subHistory = new History(); diff --git a/test/source/page/NestedRouter.tsx b/test/source/page/NestedRouter.tsx index 6fbe119..eac8c57 100644 --- a/test/source/page/NestedRouter.tsx +++ b/test/source/page/NestedRouter.tsx @@ -1,39 +1,45 @@ import { createCell, component } from 'web-cell'; import { observer } from 'mobx-web-cell'; -import { HTMLRouter } from '../../../source'; +import { HTMLRouter, matchRoutes } from '../../../source'; -import { nestedHistory, simpleHistory } from '../model'; -import SimpleRouter from './SimpleRouter'; +import { subHistory } from '../model'; + +function Sample({ path }) { + return {path}; +} + +function Temp({ path }) { + return {path}; +} @observer @component({ - tagName: 'nested-router', + tagName: 'sub-router', renderTarget: 'children' }) -export default class NestedRouter extends HTMLRouter { - protected history = nestedHistory; - - renderPage() { - if (nestedHistory.path !== 'nested') - return
{nestedHistory.path}
; - - simpleHistory.mount(); - - return ; - } +export default class SubRouter extends HTMLRouter { + protected history = subHistory; render() { return (
- {this.renderPage()} +
+ {matchRoutes( + [ + { paths: ['sample'], component: Sample }, + { paths: ['temp'], component: Temp } + ], + subHistory.path + )} +
); } diff --git a/test/source/page/SimpleRouter.tsx b/test/source/page/SimpleRouter.tsx index 16ee246..426e3ba 100644 --- a/test/source/page/SimpleRouter.tsx +++ b/test/source/page/SimpleRouter.tsx @@ -1,16 +1,30 @@ import { createCell, component } from 'web-cell'; import { observer } from 'mobx-web-cell'; -import { HTMLRouter } from '../../../source'; +import { HTMLRouter, matchRoutes } from '../../../source'; -import { simpleHistory } from '../model'; +import { topHistory } from '../model'; +import SubRouter from './NestedRouter'; + +function Test({ path }) { + return {path}; +} + +function Example({ path }) { + return ( +
+ {path} + +
+ ); +} @observer @component({ - tagName: 'simple-router', + tagName: 'top-router', renderTarget: 'children' }) -export default class SimpleRouter extends HTMLRouter { - protected history = simpleHistory; +export default class TopRouter extends HTMLRouter { + protected history = topHistory; render() { return ( @@ -23,7 +37,15 @@ export default class SimpleRouter extends HTMLRouter { Example -
{simpleHistory.path}
+
+ {matchRoutes( + [ + { paths: ['test'], component: Test }, + { paths: ['example'], component: Example } + ], + topHistory.path + )} +
); } diff --git a/test/source/page/index.tsx b/test/source/page/index.tsx index f7668a8..0bc0236 100644 --- a/test/source/page/index.tsx +++ b/test/source/page/index.tsx @@ -1,11 +1,9 @@ import { createCell, render } from 'web-cell'; -import SimpleRouter from './SimpleRouter'; -import NestedRouter from './NestedRouter'; +import TopRouter from './SimpleRouter'; render(
- - +
); diff --git a/test/utility.spec.ts b/test/utility.spec.ts index b01996c..823039e 100644 --- a/test/utility.spec.ts +++ b/test/utility.spec.ts @@ -1,22 +1,12 @@ import './DOM-polyfill'; -import { parseURL } from '../source/utility'; +import { parsePathData } from '../source/utility'; describe('Utility methods', () => { it('should parse URL to parts object', () => { - expect(parseURL('test/example?a=1&b=2&b=3')).toEqual( + expect(parsePathData('test/example?a=1&b=2&b=3')).toEqual( expect.objectContaining({ - hash: '', - host: 'localhost', - hostname: 'localhost', - href: 'http://localhost/test/example?a=1&b=2&b=3', - origin: 'http://localhost', - password: '', - pathname: '/test/example', - port: '', - protocol: 'http:', - search: '?a=1&b=2&b=3', - searchParams: { a: 1, b: [2, 3] }, - username: '' + path: 'test/example', + params: { a: 1, b: [2, 3] } }) ); }); diff --git a/tsconfig.json b/tsconfig.json index 4fcdf31..0b69dec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,9 @@ { "compilerOptions": { "target": "es5", - "experimentalDecorators": true + "experimentalDecorators": true, + "jsx": "react", + "jsxFactory": "createCell" }, "include": ["source/**/*"] }