Router入門0x202: 自己實現 Router 頁面排程和特定頁面訪問

followWinter發表於2018-09-28

0x000 概述

上一章講了SPA如何實現元件/頁面切換,這一章講如何解決上一章出現的問題以及如何優雅的實現頁面切換。

0x001 問題分析

回顧一下上一章講的頁面切換,我們通過LeactDom.render(new ArticlePage(),document.getElementById(`app`))來切換頁面,的確做到了列表頁和詳情頁的切換,但是我們可以看到,瀏覽器的網址始終沒有變化,是http://localhost:8080,那如果我們希望直接訪問某個頁面,比如訪問某篇文章呢?也就是我們希望我們訪問的地址是http://localhost:8080/article/1,進入這個地址之後,可以直接訪問id 為1的文章。
問題1:無法做到訪問特定頁面並傳遞引數
問題2:通過LeactDom.render(new ArticlePage(),document.getElementById(`app`))太冗餘了

0x002 簡單的路由實現並和原生js結合:

基本上也是基於釋出-訂閱模式,
register: 註冊路由
push: 路由跳轉

  • 原始碼

class Router {
    static routes = {}

    /**
     * 如果是陣列
     * 就遍歷陣列並轉化成 {"/index":{route:{...},callback:()=>{....}}} 形式
     * 並執行 init 方法
     * 如果是物件
     * 就轉化成 {"/index":{route:{...},callback:()=>{....}}} 形式
     * 並和原來的 this.route 合併
     * 注意: 如果用物件形式必須手動執行 init 方法
     * 最終 this.route 形式為
     * [
     *  {"/index":{route:{...},callback:()=>{....}}}
     *  {"/detail":{route:{...},callback:()=>{....}}}
     * ]
     * @param routes
     * @param callback
     */
    static register(routes, callback) {
        if (Array.isArray(routes)) {
            this.routes = routes.map(route => {
                return {
                    [route.path]: {
                        route: route,
                        callback: callback
                    }
                }
            }).reduce((r1, r2) => {
                return {...r1, ...r2}
            })
        }
        this.routes = {
            ...this.routes,
            ...{
                [routes.path]: {
                    route: routes,
                    callback: callback
                }
            }
        }
    }

    /**
     * 跳轉到某個路由
     * 本質是遍歷所有路由並執行 callback
     *
     * @param path
     * @param data
     */
    static push(path, data) {
        Object.values(this.routes).forEach(route => {
            route.callback(data, this.routes[ path].route, path)
        })
    }

}

export default Router
  • 使用

    import Router from "./core/Router";
    
    Router.register([
        {
            path: "/index",
            name: "主頁",
            component: (props) => {
                return document.createTextNode(`這是${props.route.name}`)
            }
        },
        {
            path: "/detail",
            name: "詳情頁",
            component: (props) => {
                return document.createTextNode(`這是${props.route.name}`)
            }
        }
    ], (data, route, match) => {
        if (route.path === match) {
            let app = document.getElementById(`app`)
            app.childNodes.forEach(c => c.remove())
            app.appendChild(new route.component({data,route,match}))
        }
    })
    
    Router.push(`/index`)
    
    setTimeout(()=>{
        Router.push(`/detail`)
    },3000)
    
  • 說明:
    push方法呼叫的時候,會觸發register的時候傳入的callback,並找到push傳入的path匹配的路由資訊,然後將該路由資訊作為callback的引數,並執行callback
    在上面的流程中,我們註冊了兩個路由,每個路由的配置資訊大概包含了pathnamecomponent三個鍵值對,但其實只有path是必須的,其他的都是非必須的,可以結合框架、業務來傳需要的引數;在註冊路由的同時傳入了路由觸發時的動作。這裡設定為將父節點的子節點全部移除後替換為新的子節點,也就達到了元件切換的功能,通過callbackprops引數,我們可以獲取到當前觸發的路由配置和觸發該路由配置的時候的資料,比如後面呼叫Route.push(`/index`,{name:1})的時候,callbackprops

    {
        data:{
            name:1
        },
        route:{ 
            path: "/index",
            name: "主頁",
            component: (props) => {
                    return document.createTextNode(`這是${props.route.name}`)
                }
        }
    }

0x003 和上一章的SPA結合

import Router from "./core/Router";
import DetailPage from "./page/DetailPage";
import ArticlePage from "./page/ArticlePage";
import LeactDom from "./core/LeactDom";

Router.register([
    {
        path: "/index",
        name: "主頁",
        component: ArticlePage
    },
    {
        path: "/detail",
        name: "詳情頁",
        component: DetailPage
    }
], (data, route,match) => {
    if (route.path !== match) return
    LeactDom.render(new route.component(data), document.getElementById(`app`))
})

然後在頁面跳轉的地方,修改為Route跳轉

    // ArticlePage#componentDidMount
    componentDidMount() {
            let articles = document.getElementsByClassName(`article`)
            ;[].forEach.call(articles, article => {
                    article.addEventListener(`click`, () => {
                        // LeactDom.render(new DetailPage({articleId: article.getAttribute(`data-id`)}), document.getElementById(`app`))
                        Router.push(`/detail`,{articleId:article.getAttribute(`data-id`)})
                    })
                }
            )
    
        }

    // DetailPage#componentDidMount
    componentDidMount() {
        document.getElementById(`back`).addEventListener(`click`, () => {
            LeactDom.render(new ArticlePage(), document.getElementById(`app`))
            Router.push(`/index`)
        })
    }

0x004 指定跳轉頁面-hash

先看結果,我們希望我們在訪問http://localhost:8080/#detail?articleId=2的時候跳轉到id=2的文章的詳情頁面,所以我們需要新增幾個方法:

    import Url from `url-parse`

class Router {
    static routes = {}

    /**
     * 初始化路徑
     * 新增 hashchange 事件, 在 hash 發生變化的時候, 跳轉到相應的頁面
     * 同時根據訪問的地址初始化第一次訪問的頁面
     *
     */
    static init() {
        Object.values(this.routes).forEach(route => {
            route.callback(this.queryStringToParam(), this.routes[`/` + this.getPath()].route,`/`+this.getPath())
        })

        window.addEventListener(`hashchange`, () => {
            Object.values(this.routes).forEach(route => {
                route.callback(this.queryStringToParam(), this.routes[`/` + this.getPath()].route,`/`+this.getPath())
            })
        })

    }

    /**
     * 如果是陣列
     * 就遍歷陣列並轉化成 {"/index":{route:{...},callback:()=>{....}}} 形式
     * 並執行 init 方法
     * 如果是物件
     * 就轉化成 {"/index":{route:{...},callback:()=>{....}}} 形式
     * 並和原來的 this.route 合併
     * 注意: 如果用物件形式必須手動執行 init 方法
     * 最終 this.route 形式為
     * [
     *  {"/index":{route:{...},callback:()=>{....}}}
     *  {"/detail":{route:{...},callback:()=>{....}}}
     * ]
     * @param routes
     * @param callback
     */
    static register(routes, callback) {
        if (Array.isArray(routes)) {
            this.routes = routes.map(route => {
                return {
                    [route.path]: {
                        route: route,
                        callback: callback
                    }
                }
            }).reduce((r1, r2) => {
                return {...r1, ...r2}
            })
            this.init()
        }
        this.routes = {
            ...this.routes,
            ...{
                [routes.path]: {
                    route: routes,
                    callback: callback
                }
            }
        }
    }

    /**
     * 跳轉到某個路由
     * 其實只是簡單的改變 hash
     * 觸發 hashonchange 函式
     *
     * @param path
     * @param data
     */
    static push(path, data) {
        window.location.hash = this.combineHash(path, data)
    }

    /**
     * 獲取路徑
     * 比如 #detail => /detail
     * @returns {string|string}
     */
    static getPath() {
        let url = new Url(window.location.href)
        return url.hash.replace(`#`, ``).split(`?`)[0] || `/`
    }

    /**
     * 將 queryString 轉化成 引數物件
     * 比如 ?articleId=1 => {articleId: 1}
     * @returns {*}
     */
    static queryStringToParam() {
        let url = new Url(window.location.href)
        let hashAndParam = url.hash.replace(`#`, ``)
        let arr = hashAndParam.split(`?`)
        if (arr.length === 1) return {}
        return arr[1].split(`&`).map(p => {
            return p.split(`=`).reduce((a, b) => ({[a]: b}))
        })[0]
    }

    /**
     * 將引數變成 queryString
     * 比如 {articleId:1} => ?articleId=1
     * @param params
     * @returns {string}
     */
    static paramToQueryString(params = {}) {
        let result = ``
        Object.keys(params).length && Object.keys(params).forEach(key => {
            if (result.length !== 0) {
                result += `&`
            }
            result += key + `=` + params[key]
        })
        return result
    }

    /**
     * 組合地址和資料
     * 比如 detail,{articleId:1} => detail?articleId=1
     * @param path
     * @param data
     * @returns {*}
     */
    static combineHash(path, data = {}) {
        if (!Object.keys(data).length) return path.replace(`/`, ``)
        return (path + `?` + this.paramToQueryString(data)).replace(`/`, ``)
    }
}

export default Router

說明:這裡修改了push方法,原本callback在這裡呼叫的,但是現在換成在init呼叫。在init中監聽了hashchange事件,這樣就可以在hash變化的時候,需要路由配置並呼叫callback。而在監聽變化之前,我們先呼叫了一次,是因為如果我們第一次進入就有hash,那麼就不會觸發hanshchange,所以我們需要手動呼叫一遍,為了初始化第一次訪問的頁面,這樣我們就可以通過不同的地址訪問不同的頁面了,而整個站點只初始化了一次(在不使用按需載入的情況下),體驗非常好,還要另外一種實行這裡先不講,日後有空獨立出來講關於路由的東西。

0x005 將自己實現的路由和React整合

  • 重構ArticlePage
class ArticlePage extends React.Component {

    render() {
        return <div>
            <h3>文章列表</h3>
            <hr/>
            {
                ArticleService.getAll().map((article, index) => {
                    return <div key={index} onClick={() => this.handleClick(article)}>
                        <h5>{article.title}</h5>
                        <p>{article.summary}</p>
                        <hr/>
                    </div>
                })
            }
        </div>

    }

    handleClick(article) {
        Router.push(`/detail`, {articleId: article.id})
    }
}
  • 重構DetailPage

class DetailPage extends React.Component {
    render() {
        const {title, summary, detail} = ArticleService.getById(this.props.data.articleId)
        return <div>
            <h3>{title}</h3>
            <p>{summary}</p>
            <hr/>
            <p>{detail}</p>
            <button className=`btn btn-success`  onClick={() => this.handleClick()}>返回</button>
        </div>
    }

    handleClick() {
        Router.push(`/index`)
    }
}
  • 重構路由配置和渲染

const routes = [
    {
        path: "/index",
        name: "主頁",
        component: ArticlePage
    },
    {
        path: "/detail",
        name: "詳情頁",
        component: DetailPage
    }
];


Router.register(routes, (data, route) => {
    let Component = route.component
    ReactDom.render(
        <Component {...{data, route}}/>,
        document.getElementById("app")
    )
})

0x006 為React定製Router元件

在上面每呼叫一次Router.push,就會執行一次ReactDom.render,並不符合React的思想,所以,需要為React定義一些元件

  • RouteApp元件
class RouterApp extends React.Component {
    componentDidMount(){
        Router.init()
    }
    render() {
        return {...this.props.children}
    }

}
  • Route元件

class Route extends React.Component {
    constructor(props) {
        super()
        this.state={
            path:props.path,
            match:``,
            data:{}
        }
    }

    componentDidMount() {
        Router.register({
            path: this.props.path
        }, (data, route) => {
            this.setState({
                match:route.path,
                data:data
            })
        })

    }

    render() {
        let Component = this.props.component
        if (this.state.path===this.state.match){
            return <Component {...this.state.data}/>
        }
        return null
    }
}
  • 使用

class App extends React.Component {
    render() {
        return (<div>
            <Route path="/index" component={ArticlePage}/>
            <Route path="/detail" component={DetailPage}/>
        </div>)
    }
}


ReactDom.render(
    <RouterApp>
        <App/> 
    </RouterApp>,
    document.getElementById(`app`)
)
  • 說明

RouterApp元件中呼叫了Route.init來初始化呼叫,然後在每個Route中註冊路由,每次路由變化的時候都會導致Route元件更新,從而使元件切換。

0x007 總結

路由本身是不帶有任何特殊的屬性的,在與框架整合的時候,應該考慮框架的特點,比如react的時候,我們可以使用reactreact-route直接結合,也可以通過使用react-route-dom來結合。

0x008 資源

相關文章