react-router-dom源码解析系列第五篇 - Route, 一起来学习Route组件的思想和继续完善我们自己的react-router库.

一、更新


[2019-4-21]

Changed

  • 改进文章排版

二、前言


上一篇主要学习了react-router-domRouter组件的基本思想和简单实现, 我们可以发现, Router作为整个体系的Powered by-动力来源, 起着至关重要的作用. 而这一篇将要学习的Route组件, 是整个体系中的执行者:

PS: 给你一个path, 你拿去, 把对应的componentrender出来.

由此可见, Route同样是多么的重要.

三、细说


3.1 前置知识

花了几分钟时间, 画了一张脑图, 大概是这一篇文章的大致脉络😰…

文章大致脉络

3.2 path-to-regexp

学习Route组件源码之前, 先来看一下这个库:

react-router-dom官方再源码中引用了这个库, 库的地址也可以在这里找到. 见名闻其意 —— path-to-regexp, 顾名思义, 就是将path, 例如url转化为不同模式的RegExp, 同时也提供了一些配置项供我们使用.

来看一下path-to-regexp库提供的这个方法:

1
2
3
4
5
const path = '/user/:id/profile/:secret';
const keys: pathToRegexp.Key[] = [];
const regexp: RegExp = pathToRegexp(path, keys, {});

console.log({ path, keys, regexp });

这是官方提供的例子, 我们在控制台打印可以看到如下结果:

path-to-regexp示例

我们可以看到, path-to-regexp根据我们输入的path路径, 以及keys空数组, 生成了对应的regexp对象, 以及保存着url参数的数组. 那么我们可以思考一下, 我们能否将这个小例子, 引申到react-router-dom中呢? 答案是肯定的.

先来捋一下Route组件的设计思路:

  1. 接收到带有{ path, sensitive, exact, … }的props
  2. 判断props是否具有Switch组件已经计算好的的match对象
    • 有的话, 执行下一步render
    • 没有的话, 计算match
  3. 根据props的{ children, component, render }执行render

再将目光转向上面提到的例子, 我们可否将例子里的path当作Routepathprops? 接着第一个例子, 抛砖引玉, 我们再来看一个:

1
2
3
4
5
6
7
8
const path = '/user/:id/profile/:secret';
const keys: pathToRegexp.Key[] = [];
const pathname = '/user/19980808/profile/duan';

const regexp: RegExp = pathToRegexp(path, keys, {});
const result = pathname.match(regexp);

console.log(result);

可以看到打印出了如下结果:

例子2

这里, 我增加了pathname常量, 根据例子一中生成的regexpmatch方法, 来生成一个捕获之后的对象. 生成的数组的前三项分别是精确匹配后的url, :id参数, :secret参数. 得出这个结果, 我们可以整合一下, 能否例子二中的pathname当作props.location.pathname?

Routepathprops根据path-to-reg生成一个regexp对象, 该regexp根据porps.location.pathname生成捕获到的对象, 该数组对象中具有匹配之后的pathname, 以及path中携带的参数.

3.3 源码分析

根据前置知识的分析, 了解到其实Route组件是引用了path-to-regexp这个库, 可以说一切的核心都是围绕这个库展开的, 下面来看一看具体的源码…

首先来看一下:

1
2
3
4
5
6
// src/yyg-react-router-dom/components/Route.js
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;

这一段代码刚好印证了之前提到了Route组件设计思路的第二步, 当然目前Switch组件的源码还没看… 其中可以看到, 当props.computedMatch不存在的话, 就会执行matchPath这个method. 接着, 我们进入到matchPath这个函数内部, 也就是Route同目录下的matchPath.js看一看…

1
2
3
4
5
// src/yyg-react-router-dom/components/matchPath.js
function matchPath(pathname, options = {}) {
...
...
}

可以看到, matchPath函数接受两个参数:

  • pathname
    • 也就是对应的context.location.pathname
  • options
    • Route接收到的props —— { path, sensitive, exact, strict }

可以说, 余下的一切都是围绕这两个参数展开的…

接着再往下看:

1
2
3
4
// src/yyg-react-router-dom/components/matchPath.js
const { path, exact = false, strict = false, sensitive = false } = options;

const paths = [].concat(path);

由于path可以将单个string, 也可以接收string[]数组作为值, 所以这里将path统一转化为数组进行操作.

1
2
3
4
5
6
// src/yyg-react-router-dom/components/matchPath.js
return paths.reduce((matched, path) => {
if(matched) {
return matched;
}
}, null);

使用了reduce方法, 通过遍历path数组, 如果迭代的match对象存在, 也就是说匹配到了一个path, 则直接返回.

紧接着来看:

1
2
3
4
5
6
// src/yyg-react-router-dom/components/matchPath.js
const { regexp, keys } = compilePath(path, {
end: exact, // 精确匹配, path === pathname
strict, // 是否忽略url后的斜杠/
sensitive, // 是否忽略大小写
});

这里, 也是重点: 通过执行一个名叫compilePath方法, 传递了相关的props参数, 接收到了{ regexp, keys }两个返回值, 是不是很熟悉? 没错, 和我们的例子一中的是一样的, 将path转化成了对应的regexp, 将path中携带的参数提取到了keys数组中.

顺藤摸瓜, 来到compilePath()函数:

1
2
3
4
5
// src/yyg-react-router-dom/components/matchPatch.js
function compilePath(path, options) {
...
...
}

该函数作为连接path-to-regexp的桥梁, 主要任务是通过传入的options配置项 ,将path转化为regexp ,该函数同样接收两个参数:

  • path 需要转化的路径
  • options 配置项
    • sensitive 是否区分大小写
    • strict 是否忽略路径后的斜杠
    • end 是否精确匹配, 在做嵌套Route的时候非常有用, 本人被坑过🙂🙂🙂…

接着下一步:

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/yyg-react-router-dom/components/Route.js
const cacheKey = '`${options.end}${options.strict}${options.sensitive}`';
const pathCache = cache[cacheKey] || (cache[cacheKey] = {});

if (pathCache[path]) return pathCache[path];

......
......

if (cacheCount < cacheLimit) {
pathCache[path] = result;
cacheCount++;
}

其中, 用到了几个全局常量对象, 这里就不做标注了.

如上所示, 其实是对path-to-regexp处理的结果进行了缓存, 由于options的参数是可选的, 所以这时对其进行缓存处理, 每一次计算regexp都会查询cache中有无缓存, 有利于优化性能. 这也是看源码值得学习的地方, 理解作者思路, 学习大牛写法🙂…

最后, 和我们之前写的例子一样:

1
2
3
4
5
const keys = [];
const regexp = pathToRegexp(path, keys, options);
const result = { regexp, keys };

return result;

将处理后的regexp返回给调用者, 供调用者使用.

分析完compilePath方法之后, 回到matchPath:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/yyg-react-router-dom/components/matchPath.js
const { regexp, keys } = compilePath(path, {
end: exact,
strict,
sensitive,
});
// const match = pathname.match(regexp);
// 两者都可, 任选其一
const match = regexp.exec(pathname);

// 匹配值为null, 直接return
if(!match) {
return null;
}

const [url, ...values] = match;

上面的源码中, 接收到了compilePath的返回值, 通过调用regexp正则对象的exec方法获取匹配值, 然后通过解构赋值, 将match匹配到的url, 也就是第一个参数赋值给了url, 该url日后将为Link组件享用. 余下的参数统一赋值给了rest参数, 方便接下来的params赋值操作…

最后一步, 返回整个match对象, 包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/yyg-react-router-dom/components/matchPath.js
...
const isExact = pathname === url;

return {
path,
url,
isExact,
params: keys.reduce((memo, key, index) => {
memo[key.name] = values[index];
return memo;
}, {}),
};
...

keys数组中保存着path中的参数, values中保存着对应的参数值, 通过reduce迭代, 最终返回match对象…


终于分析完了matchPath这个方法, 我们回到Route.js文件, 继续之前的进度, 接着往下看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/yyg-react-router-dom/components/Route.js
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) {
if(__DEV__) {
const { path } = this.props;

warning(...);
}

children = null;
}
}

上述代码中, 通过es6的解构赋值, 将原context中的locationmatch对象替换成处理过的location和match. 接着, 由于Route组件可能接收到{ children, render, component }这三个props, 所以这里分别做了处理… 当然, 上面有一段兼容preact框架的代码, 这里就不管他了😀…

最后, 到了render环节, 继续来看源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/yyg-react-router-dom/components/Route.js
return (
<RouterContext.Provider value={props}>
children && !isEmptyChildren(children)
? children
: props.match
? component
? React.createElement(component, props)
: render
? render(props)
: null
: null
</RouterContext.Provider>
);

render中, 将更新之后的context提供给Provider, 然后再根据children -> component -> render的顺序依次判断是否据此渲染.

其中有使用到了一个名为isEmptyChildren的方法, 在vscode内追踪到该函数:

1
2
3
4
// 判断props.children是否为空?
function isEmptyChildren(children) {
return React.Children.count(children) === 0;
}

我们发现, 该函数内部其实是调用了React的静态方法, 所以, 不是那么神秘.

到了这里, 基本上Route组件的源码已经看完了, 剩下了一些Error或者Warning处理, 可以当作参考. 下面该继续来完善自己的react-router-dom库…

四、实践


PS: 隔了一天写的, 接着上面的源码简单分析, 今天主要是完善一下自己的yyg-react-router-dom库

话不多说, 顺着昨天的思路: Route通过传入的props计算match, 在计算match的过程中引用了path-to-regexpnpm包, 用来转化pathRegExp, 从而与location.pathname作匹配, 得出计算之后的match对象.

紧接着, 由于Route的props有三种渲染方式-children | render | component, 所以这里做了一些判断, 同时, 加入了对preact的兼容处理以及错误处理.

那么, 回顾了一下昨天的思路, 就开始动手coding了…

4.1 定义interface

PS: 由于之前已经写入了基本结构, 所以首先要约束所需的props

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/yyg-react-router-dom/components/route.tsx
...
export interface IRouteProps {
location?: Location;
component?: React.ComponentType<IStaticRouteComponentParams> | React.ComponentType<any>;
render?: ((props: IStaticRouteComponentParams) => React.ReactNode);
children?: ((props: IStaticRouteComponentParams) => React.ReactNode) | React.ReactNode;
path?: string | string[];
exact?: boolean;
sensitive?: boolean;
strict?: boolean;
};
...

4.2 拆分处理函数

本来, 这里是不想写的. 但是, 由于比较重要, 也当作是加深记忆把, 所以还是记录下来…

PS: 在render中, 尽量减少逻辑代码

也就是说, 尽量将render中的处理逻辑拆分成单个函数来处理, 这个, 我觉得对于整个组件的整洁是非常重要的…

了解了这点, 我们可以这样 —— 将render中的处理逻辑提取至handleProcess这个处理函数:

1
2
3
4
5
6
7
return (
<RouterContext.Consumer>
{(context: IStaticRouteComponentParams) => {
return handleProcess(context);
}}
</RouterContext.Consumer>
);

我们在组件中生成一个名为handleProcess的主进程处理函数, 该函数接收一个名为context的参数.

1
2
3
4
5
6
7
8
9
10
11
12
function handleProcess(
context: IStaticRouteComponentParams,
): JSX.Element {
// 将任务分发至 多个子进程
const xxx1 = handleXXX1();
const xxx2 = handleXXX2(xxx1);
const xxx3 = handleXXX3(xxx2);

return (
<div />
);
}

这样一来, 整个代码的可读性就好了很多, 同时美观程度也大有改观.

4.3 计算location

location的来源有两个方面:

  • context.location
  • props.location

当然, props的优先级肯定是要比context的高了, 所以对其作一下简单的处理:

1
2
3
4
5
6
// src/yyg-react-router-dom/components/route.tsx
return props.location
? props.location
: context.location
? context.location
: History.createLocation('/');

4.4 计算match

主函数接收到计算后的location之后, 将其传递给matchProcess, 也就是这一步 —— 计算match:

1
2
3
// src/yyg-react-router-dom/components/route.tsx
const location = handleLocationProcess(context);
const match = handleMatchProcess(context, location);

对应的match处理主函数接收到contextlocation, 进行一系列判断, 如果父组件不是Switch并且props.path存在, 则进入computeMatchProcess处理函数:

1
2
3
4
5
6
// src/yyg-react-router-dom/components/route.tsx
return props.computedMatch
? props.computedMatch
: props.path
? computeMatchProcess(location)
: context.match;

在computeMatchProcess中, 我们要做的就是对path进行迭代处理, 将处理后的match对象返回, 该match可能为null或者键值对. 这样, 就可以在render阶段根据match的值存在与否来决定是否渲染对应的components.

注意, computedMatchProcess中引入了computePath辅助函数, 主要是为了代码分割, 避免一个函数内部的代码过于繁杂…

4.5 融合

计算完相应的match对象, 在我们自己的主进程处理函数中, 现在已经获取到了计算完毕的locationmatch对象. 这一步, 就是将原来的context中的对应的location和match替换为计算之后的, 也就是融合.

1
const composedContext = {...context, match, location};

4.6 重置Provider

将融合后的context, 作为新的value传递给Provider

1
2
3
4
5
6
7
return (
<RouterContext.Provider
value={composedContext}
>
{ ...render... }
</RouterContext.Provider>
);

4.7 render

根据不同的途径 —— render | component | children, 来进行渲染:

PS: react-router-dom官方采用的渲染权重是: children > component > render

在这里, 我们自己玩, 所以不必在意这个东西

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
return (
<RouterContext.Provider
value={composedContext}
>
props.children
? props.children
? typeof props.children === 'function'
? props.children(composedContext as IStaticRouteComponentParams)
: props.children
: props.children
: match
? props.component
? React.createElement(props.component, composedContext)
: props.render
? props.render(composedContext as IStaticRouteComponentParams)
: null
: null
</RouterContext.Provider>
);

五、测试


经过上面的实践环节, Route组件基本完成了, 现在应该做个测试…

src/test下新建一个测试组件, 内容随意

src/App.tsx中引入我们自己的Route和测试组件:

1
2
3
4
5
6
// src/App.tsx
import {
BrowserRouter,
Route,
} from "./yyg-react-router-dom/index";
import Test from "./test/One.tsx";

App中写入我们的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/App.tsx
const App = React.memo<IAppProps>((
props: IAppProps,
): JSX.Element => {
return (
<div>
<BrowserRouter>
<Route
path={["/user/:name/profile/:secret"]}
exact
component={One}
/>
</BrowserRouter>
</div>
);
});

打开浏览器, 可以看到, 我们自己的组件可以正常渲染, 并且测试组件Test.tsx中也正常打印出了context对象, 像这样:

动图展示测试Route组件

测试成功exactcomponent配置项之后, 再来试一下render.

src/test/下新建Three.tsx, 作为我们的第三个测试组件, 当然, 内容随意, 能展示出具体内容就ok:

1
2
3
4
5
6
// src/test/Three.tsx
...
return (
<h1>Three pages&</h1>
);
...

然后, 在src/App.tsx中引入该组件, 并且更改测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/App.tsx
import Three from ./test/Three;

return (
<div>
<BrowserRouter>
<Route
path={["/user/:name/profile/:secret"]}
render={() => (
<div>
<Route path="/user/:name/profile/:secret" exact component={One} />
<Route path="/user/:name/profile/:secret/test" exact component={Three} />
</div>
)}
/>
</BrowserRouter>
</div>
);

可以看到, 这个开发中经常用到的结构, 完成之后, 重新编译, 在CentBrowser中作测试:

首先, 当url为/user/:name/profile/:secret时, 也就是匹配到了component为OneRoute, 理所当然, 渲染这个组件:

测试render嵌套Route

可以看到, 结果是预期所示的, 没有问题, 那么, 接着将url改为Three组件对应的path, 再来看:

测试render嵌套Route

结果是Three组件的内容被完全渲染了出来, 这也证实, 我封装的组件系没有啥大问题的📍

同理, 测试其他配置项都是没有问题的, 这里由于文章篇幅太长, 就不一一展示了…

六、源码


源码地址: 点我

七、总结


写了两天, 终于搞定了这篇文章, 还是收获颇丰的: 通过Route组件, 掌握了内部的运作机理, 比如match计算, location计算等, 又通过计算match, 初步了解了path-to-regexp这个库.

PS: 不静下心看看源码, 永远不知道你和别人查了多远

学无止境, 下一篇文章继续阅读学习Switch组件的源码, 再会!