前端路由的设计思想与实现 && React-Router v4

Author Avatar
GeniusFunny 2月 25, 2019
  • 在其它设备中阅读本文章

前端路由的实现

前端路由分为两种实现方式:

  1. HashHistory,通过修改location.hash,然后监听hashchange事件来进行对应操作。
  2. BroswerHistory,通过HTML5提供的History对象的pushStatereplaceStateAPI,然后监听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) //监听hash的变化,然后调用更新函数
}
route(path, cb = function() {}) {
this._routes.set(path, cb)
}
refresh = () => {
this._currentUrl = location.hash.slice(1) || '/' //读取url中'#'后面的内容
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
//	创建context,用于全局传递信息
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 is a bit of a hack. We have to start listening for location
// changes here in the constructor in case there are any <Redirect>s
// on the initial render. If there are, they will replace/push when
// they mount and since cDM fires in children before parents, we may
// get a new location before the <Router> is mounted.
this._isMounted = false;
this._pendingLocation = null;
if (!props.staticContext) {
// !!!在这里进行监听,每次路由变化都会触发顶层Router的回调事件,然后Router进行setState,再向下传递context,最下面的Route根据context内容判断是否进行渲染!!!
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 // <Switch> already computed the match for us
: this.props.path
? matchPath(location.pathname, this.props)
: context.match

const props = { ...context, location, match } // 传递给要渲染的组件

let { children, component, render } = this.props

// Preact uses an empty array as children by
// default, so use null if that's the case.
if (Array.isArray(children) && children.length === 0) {
children = null;
}
if (typeof children === "function") {
children = children(props);

if (children === undefined) {
children = null;
}
}

return (
// 提供了多种渲染的方式,children(子元素)、component(props)、render(props)
<RouterContext.Provider value={props}>
{children && !isEmptyChildren(children)
? children
: props.match
? component
? React.createElement(component, props) // 这里是通过React.creareElement创建组件的,所以React Diff认为每次都不一样,更新的时候会先卸载之前的组件再重新安装新的组件。
: 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;

// We use React.Children.forEach instead of React.Children.toArray().find()
// here because toArray adds keys to all child elements and we do not want
// to trigger an unmount/remount for two <Route>s that render the same
// component at different URLs.
React.Children.forEach(this.props.children, child => {
// 按children的顺序依次遍历子元素,成功就标记这个子元素和对应的location和comutedMatch,最后调用React.cloneElement渲染,否则返回null
if (match == null && React.isValidElement(child)) {
element = child;

const path = child.props.path || child.props.from;
// Switch通过matchPath来判断是否匹配成功
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;
}

/**
* Public API for matching a URL pathname to a path.
*/
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, // 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) => {
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 && // onClick prevented default
event.button === 0 && // ignore everything but left clicks
(!this.props.target || this.props.target === "_self") && // let browser handle "target=_blank" etc.
!isModifiedEvent(event) // ignore clicks with modifier keys
) {
event.preventDefault();

const method = this.props.replace ? history.replace : history.push;

method(this.props.to);
}
}

render() {
const { innerRef, replace, to, ...rest } = this.props; // eslint-disable-line no-unused-vars

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
) {
// TODO: If another transition starts while we're still confirming
// the previous one, we may end up in a weird state. Figure out the
// best way to handle this.
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 {
// Return false from a transition hook to cancel the transition.
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);
}

总结

现在我们来大致梳理一下,前端路由的过程。

  1. 当Router组件创建时,我们会在上面注册一个事件用于监听路由的变化,一旦路由变化就会触发setState。
  2. 点击Link其实就是点击a标签进行跳转,但是我们阻止了跳转;实际上我们在这里调用history的pushState方法,将当前path存储,并修改路由(此时并没有改变页面的渲染)。
  3. 每次路由的变化就会触发Router的setState,所以此时会成新context传递给下层。
  4. 下层的Route拿到新的context后根据matchPath判断path是否和location匹配,匹配就渲染,不匹配不渲染,此时页面的渲染改变

所以说整个前端路由的思路(以React-Router为例)就是:

  1. 首先创建一个全局对象,通过context API向下传递histpry对象,从而控制具体的渲染;(渲染变化)
  2. 添加一个Event Emitter,用于响应路由变化/Hash变化,然后进行对应的逻辑处理;(路由变化)
  3. 路由变化/Hash变化导致context变化,context变化又导致渲染改变;(渲染变化)

那么,前端路由跟后端路由有什么区别呢?

后端路由,数据渲染是由服务器解决,例如利用express构建的服务端应用,render(‘xxx.pug’),服务器渲染好页面后返回给浏览器端,浏览器显示。表现形式就是,跳转页面会白屏,加载~

前端路由,数据渲染是由浏览器端解决,例如上述的例子,但是路由变化和渲染变化是分隔开的,所以我们需要通过hashchange or popstate将路由变化和渲染变化连接起来。表现形式则是,跳转页面不会白屏~

那是不是前端路由就可以完全取代后端路由了呢?不是的,前端路由也存在不足,SEO就是一个big problem。

那是不是前端路由和后端路由就是相互隔绝的呢?不是的,二者之间又衍生出许多新的hack手段,例如预渲染(服务端线构建出一部分静态HTML,用于直出浏览器,然后剩余使用前端渲染来实现)、SSR。

既然谈到了预渲染和SSR;那就先占个坑位,学习一下SSR和预渲染~

参考资源

  1. 前端路由的两种实现原理
  2. 面试官:你了解前端路由吗
  3. React-Router源码
  4. history源码—Manage session history with JavaScript