前端路由的实现 前端路由分为两种实现方式:
HashHistory ,通过修改location.hash
,然后监听hashchange
事件来进行对应操作。
BroswerHistory ,通过HTML5提供的History对象的pushState
、replaceState
API,然后监听popstate
事件来进行对应操作。
二者的优劣:
第一种方法兼容性更好,但是实现比较复杂 ,并且url比较丑陋 (例如:http://www.test.com/#/xxx
)。
第二种方法是浏览器提供的能力,所以实现比较简单,url与正常相同(例如:http://www.test.com/xxx
),但是可能存在兼容性问题 。
其实还有第三种,MemoryHistory ,用于non-DOM环境下,例如React Native。
基于hash的前端路由实现 _routes用来存储路由对应的回调函数,当hash变化时调用refresh函数(从_routes总找到对应的回调函数然后执行);通过back函数进行回退,所以我们需要引入布尔值判定当前操作是否为回退。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 class HashRouter { constructor () { this ._routes = new Map () this ._history = [] this ._currentUrl = '' this ._backIndex = this ._routes.length - 1 this ._currentIndex = this ._routes.length - 1 this ._isBack = false this ._bindEvent() } _bindEvent = () => { window .addEventListener('laod' , this .refresh, false ) window .addEventListener('hashchange' , this .refresh, false ) } route(path, cb = function ( ) {}) { this ._routes.set(path, cb) } refresh = () => { this ._currentUrl = location.hash.slice(1 ) || '/' this ._history.push(this ._currentUrl) this ._currentIndex++ if (!this ._isBack) { this ._backIndex = this ._currentIndex } let cb = this ._routes.get(this ._currentUrl) if (typeof cb === 'function' ) cb() this ._isBack = false } back = () => { this ._isBack = true if (this ._backIndex <= 0 ) { this ._backIndex = 0 } else { this ._backIndex -= 1 } location.hash = `#{this._history[this._backIndex]}` } }
基于History的前端路由实现 依赖于HTML5提供的History对象,Router的实现简便很多;使用go来进行前往指定路由,用back回退。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class BroswerRouter { constructor () { this ._routes = new Map () this ._bindEvent() } _bindEvent = () => { window .addEventListener('popstate' , e => { let path = e.state && e.state.path let cb = this ._routes.get(path) if (typeof cb = 'function' ) cb() }) } init(path) { history.replaceState({path : path}, '' , path) let cb = this ._routes.get(path) if (typeof cb = 'function' ) cb() } route(path, cb = function ( ) {}) { this ._routes.set(path, cb) } go = (path ) => { history.pushState({path : path}, '' , path) let cb = this ._routes.get(path) if (typeof cb === 'function' ) cb() } back = () => { history.back() } }
React-Router v4 👆👆👆只是简单的前度路由的模拟,但是涉及到React或者Vue的时候,我们需要另外处理渲染逻辑,所以会牵扯到生命周期和组件通信。
下面简单分析概述一下React-Router v4
模块结构 React-Router分为4个package,分别为:react-router、react-router-dom、react-router-native、react-router-config。
react-router :负责通用的路由逻辑,被其他package所依赖,无需特别引入。
react-router-dom : 负责浏览器的路由管理。
react-router-native:负责react-native的路由管理。
react-router-config: 用于配置特别的react-router,比如SSR环境下。
下面介绍一下react-router中只要的模块👇👇👇
Router(react-router/Router、react-router/RouterContext、react-router-dom/BrowserRouter) BroswerRouter在内部创建一个全局对象history,然后通过props传递给Router组件,Router组件再将这个history属性作为context传递给子组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 const createNamedContext = name => { const context = React.createContext(); context.Provider.displayName = `${name} .Provider` ; context.Consumer.displayName = `${name} .Consumer` ; return context; } const RouterContext = createNamedContext('Router' )class Router extends Component { static computeRootMatch(pathname) { return { path : "/" , url : "/" , params : {}, isExact : pathname === "/" }; } constructor (props) { super (props); this .state = { location: props.history.location }; this ._isMounted = false ; this ._pendingLocation = null ; if (!props.staticContext) { this .unlisten = props.history.listen(location => { if (this ._isMounted) { this .setState({ location }); } else { this ._pendingLocation = location; } }); } } componentDidMount() { this ._isMounted = true ; if (this ._pendingLocation) { this .setState({ location : this ._pendingLocation }); } } componentWillUnmount() { if (this .unlisten) this .unlisten(); } render() { return ( <RouterContext.Provider children={this .props.children || null } value={{ history: this .props.history, location: this .state.location, match: Router.computeRootMatch(this .state.location.pathname), staticContext: this .props.staticContext }} /> ); } } class BrowserRouter extends React .Component { history = createHistory(this .props); render() { return <Router history ={this.history} children ={this.props.children} /> ; } }
Route(react-router/Route) Router是借助context向Route传递内容的,Router作为Provider、Route作为Comsumer,所以Route必须包含在Router内部。
同时,Route借助context将history、location、match作为三个独立的属性传递给要渲染的组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 class Route extends Component { render() { return ( <RouterContext.Consumer> {context => { const location = this .props.location || context.location const match = this .props.computedMatch ? this .props.computedMatch : this .props.path ? matchPath(location.pathname, this .props) : context.match const props = { ...context, location, match } let { children, component, render } = this .props if (Array .isArray(children) && children.length === 0 ) { children = null ; } if (typeof children === "function" ) { children = children(props); if (children === undefined ) { children = null ; } } return ( <RouterContext.Provider value={props}> {children && !isEmptyChildren(children) ? children : props.match ? component ? React.createElement(component, props) : render ? render(props) : null : null } </RouterContext.Provider> ); }} </ RouterContext.Consumer> ); } }
Switch(react-router/Switch) Switch的作用是,当Switch中的第一个Route匹配后就不会渲染其他的Route,类似swich这个语法。源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 class Switch extends Component { render() { return ( <RouterContext.Consumer> {context => { const location = this .props.location || context.location; let element, match; React.Children.forEach(this .props.children, child => { if (match == null && React.isValidElement(child)) { element = child; const path = child.props.path || child.props.from; match = path ? matchPath(location.pathname, { ...child.props, path }) : context.match; } }); return match ? React.cloneElement(element, { location, computedMatch : match }) : null ; }} </RouterContext.Consumer> ); } }
matchPath(react-router/matchPath) 前面多次提到使用matchPath来判断是否匹配成功,那么到底是如何进行匹配的呢?下面是matchPath的源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 const cache = {};const cacheLimit = 10000 ;let cacheCount = 0 ;function compilePath (path, options ) { const cacheKey = `${options.end} ${options.strict} ${options.sensitive} ` ; const pathCache = cache[cacheKey] || (cache[cacheKey] = {}); if (pathCache[path]) return pathCache[path]; const keys = []; const regexp = pathToRegexp(path, keys, options); const result = { regexp, keys }; if (cacheCount < cacheLimit) { pathCache[path] = result; cacheCount++; } return result; } function matchPath (pathname, options = {} ) { if (typeof options === "string" ) options = { path : options }; const { path, exact = false , strict = false , sensitive = false } = options; const paths = [].concat(path); return paths.reduce((matched, path ) => { if (matched) return matched; const { regexp, keys } = compilePath(path, { end: exact, strict, sensitive }); const match = regexp.exec(pathname); if (!match) return null ; const [url, ...values] = match; const isExact = pathname === url; if (exact && !isExact) return null ; return { path, url: path === "/" && url === "" ? "/" : url, isExact, params: keys.reduce((memo, key, index ) => { memo[key.name] = values[index]; return memo; }, {}) }; }, null ); }
Link(react-router-dom/Link) Link是react-router-dom中的,而不是通用模块里的元素;Link是一个用a标签包裹、用来实现跳转的元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 class Link extends React .Component { handleClick(event, history) { if (this .props.onClick) this .props.onClick(event); if ( !event.defaultPrevented && event.button === 0 && (!this .props.target || this .props.target === "_self" ) && !isModifiedEvent(event) ) { event.preventDefault(); const method = this .props.replace ? history.replace : history.push; method(this .props.to); } } render() { const { innerRef, replace, to, ...rest } = this .props; return ( <RouterContext.Consumer> {context => { const location = typeof to === "string" ? createLocation(to, null , null , context.location) : to; const href = location ? context.history.createHref(location) : "" ; return ( <a {...rest} onClick={event => this .handleClick(event, context.history)} href={href} ref={innerRef} /> ); }} </RouterContext.Consumer> ); } }
H5的history对象中通过pushState、replaceState只会改变路由而不会发生跳转,但是之前提的Router中的listen可以监听到路由的变化然后更新props和context,让下层的Route重新匹配,完成需要渲染部分的更新。如何监听的呢?这部分在history库中的createTransitionManager中(React-Router依赖于history)。
createTransitionManager(history/createTransitionManager) 下面就是源码,观察者模式的一个应用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 function createTransitionManager ( ) { let prompt = null ; function setPrompt (nextPrompt ) { prompt = nextPrompt; return () => { if (prompt === nextPrompt) prompt = null ; }; } function confirmTransitionTo ( location, action, getUserConfirmation, callback ) { if (prompt != null ) { const result = typeof prompt === 'function' ? prompt(location, action) : prompt; if (typeof result === 'string' ) { if (typeof getUserConfirmation === 'function' ) { getUserConfirmation(result, callback); } else { callback(true ); } } else { callback(result !== false ); } } else { callback(true ); } } let listeners = []; function appendListener (fn ) { let isActive = true ; function listener (...args ) { if (isActive) fn(...args); } listeners.push(listener); return () => { isActive = false ; listeners = listeners.filter(item => item !== listener); }; } function notifyListeners (...args ) { listeners.forEach(listener => listener(...args)); } return { setPrompt, confirmTransitionTo, appendListener, notifyListeners }; }
WithRouter(react-router/WithRouter) 光从名字上看就知道,这是一个HOC,这个高阶组件的作用就是让我们在非Route中的组件也能获取到路由信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 function withRouter (Component ) { const C = props => { const { wrappedComponentRef, ...remainingProps } = props; return ( <Route children={routeComponentProps => ( <Component {...remainingProps} {...routeComponentProps} ref={wrappedComponentRef} /> )} /> ); }; C.displayName = `withRouter(${Component.displayName || Component.name} )` ; C.WrappedComponent = Component; return hoistStatics(C, Component); }
总结 现在我们来大致梳理一下,前端路由的过程。
当Router组件创建时,我们会在上面注册一个事件用于监听路由的变化,一旦路由变化就会触发setState。
点击Link其实就是点击a标签进行跳转,但是我们阻止了跳转;实际上我们在这里调用history的pushState方法,将当前path存储,并修改路由 (此时并没有改变页面的渲染)。
每次路由的变化就会触发Router的setState,所以此时会成新context传递给下层。
下层的Route拿到新的context后根据matchPath判断path是否和location匹配,匹配就渲染,不匹配不渲染,此时页面的渲染改变 。
所以说整个前端路由的思路(以React-Router为例)就是:
首先创建一个全局对象,通过context API向下传递histpry对象,从而控制具体的渲染;(渲染变化)
添加一个Event Emitter,用于响应路由变化/Hash变化,然后进行对应的逻辑处理;(路由变化)
路由变化/Hash变化导致context变化,context变化又导致渲染改变;(渲染变化)
那么,前端路由跟后端路由有什么区别呢?
后端路由,数据渲染是由服务器解决,例如利用express构建的服务端应用,render(‘xxx.pug’),服务器渲染好页面后返回给浏览器端,浏览器显示。表现形式就是,跳转页面会白屏,加载~
前端路由,数据渲染是由浏览器端解决,例如上述的例子,但是路由变化和渲染变化是分隔开的,所以我们需要通过hashchange
or popstate
将路由变化和渲染变化连接起来。表现形式则是,跳转页面不会白屏~
那是不是前端路由就可以完全取代后端路由了呢?不是的,前端路由也存在不足,SEO就是一个big problem。
那是不是前端路由和后端路由就是相互隔绝的呢?不是的,二者之间又衍生出许多新的hack手段,例如预渲染(服务端线构建出一部分静态HTML,用于直出浏览器,然后剩余使用前端渲染来实现)、SSR。
既然谈到了预渲染和SSR;那就先占个坑位,学习一下SSR和预渲染~
参考资源
前端路由的两种实现原理
面试官:你了解前端路由吗
React-Router源码
history源码—Manage session history with JavaScript