前言
最近被问到 Vue Router
的路由匹配的一些问题
所以这次写写在 Vue Router@3
和 Vue Router@4
中的路由匹配算法
正文
Vue Router
是 Vue
的官方的路由库
对于 Vue2
配套的版本是 Vue Router@3
,而 Vue3
配套的版本则是 Vue Router@4
不管是哪个版本,Vue Router
对于路由的解析的本质都是一样的,那就是把 path
参数解析成一个正则对象
在 Vue Router@3
中,使用了 path-to-regexp
这个库来将 path
转成 regexp
这点我们可以从官方文档得知 Vue Router@3 基础/动态路由匹配/高级匹配模式
而在 Vue Router@4
中,使用了自己的匹配算法,文档上提示是“灵感来源于 express
” Vue Router@4 基础/动态路由匹配/高级匹配模式
express
是一个 node
上的简单的 web
框架,可以快速的搭建一个 web
服务器,它的路由导航支持复杂的匹配模式
所以我们会从 Vue Router
这两种匹配方式来查看它们之间的区别
Vue Router@3 匹配算法 path-to-regexp
该库提供了字符串转正则的能力,目前用于 Vue Router@3
的版本,仓库地址: pillarjs / path-to-regexp
它的源码构成非常简单,就一个文件,在 src
下的 index.ts
对于源代码,其实主要就是三个函数
lexer
parse
tokensToRegexp
lexer
在源码中,对于一个 path
,最先我们需要给他转成 LexToken
类型,该类型定义如下:
其中 type
为该 LexToken
类型, index
为起始索引, value
为对应的值
lexer
函数会去遍历 path
,对于每个字符或者字符串,都有其对应的 type
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
| function lexer(str: string): LexToken[] { const tokens: LexToken[] = []; let i = 0;
while (i < str.length) { const char = str[i];
if (char === "*" || char === "+" || char === "?") { tokens.push({ type: "MODIFIER", index: i, value: str[i++] }); continue; }
if (char === "\\") { tokens.push({ type: "ESCAPED_CHAR", index: i++, value: str[i++] }); continue; }
if (char === "{") { tokens.push({ type: "OPEN", index: i, value: str[i++] }); continue; }
if (char === "}") { tokens.push({ type: "CLOSE", index: i, value: str[i++] }); continue; }
if (char === ":") { tokens.push({ type: "NAME", index: i, value: name }); continue; }
if (char === "(") { tokens.push({ type: "PATTERN", index: i, value: pattern }); continue; }
tokens.push({ type: "CHAR", index: i, value: str[i++] }); }
tokens.push({ type: "END", index: i, value: "" });
return tokens; }
|
对于修饰符,我们可以用以下例子(这里我自行打包导出了 lexer
这个函数,源文件是没有导出这个函数的
1 2
| console.log(lexer("?")); console.log(lexer("\\?"));
|
执行之后得到如下结果
对于修饰符来说,它是有功能的,参数数量可以使用 ?
来表示 0
或 1
个
比如 /:foo?
代表 foo
这个参数可以是有,也可以是没有
而对于转义字符,它就只是一个单纯的字符而已,只是为了防止被当成修饰符
比如 /foo\\?a=1
这里的 ?
就只是表示一个单纯的字符而已,所以需要给它转义
在上面的代码中我们省略了一些逻辑,其中对于路径参数的解析为
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
| if (char === ":") { let name = ""; let j = i + 1;
while (j < str.length) { const code = str.charCodeAt(j);
if ( (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95 ) { name += str[j++]; continue; }
break; }
if (!name) throw new TypeError(`Missing parameter name at ${i}`);
tokens.push({ type: "NAME", index: i, value: name }); i = j; continue; }
|
这段逻辑我觉得很简单,大家应该都能看懂,就是把 :
后面的 [a-zA-z0-9_]
的字符当成名字
比如我们输入 /:pathName
,那么解析如下:
对于正则,解析的代码为
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
| if (char === "(") { let count = 1; let pattern = ""; let j = i + 1;
if (str[j] === "?") { throw new TypeError(`Pattern cannot start with "?" at ${j}`); }
while (j < str.length) { if (str[j] === "\\") { pattern += str[j++] + str[j++]; continue; }
if (str[j] === ")") { count--; if (count === 0) { j++; break; } } else if (str[j] === "(") { count++; if (str[j + 1] !== "?") { throw new TypeError(`Capturing groups are not allowed at ${j}`); } }
pattern += str[j++]; }
if (count) throw new TypeError(`Unbalanced pattern at ${i}`); if (!pattern) throw new TypeError(`Missing pattern at ${i}`);
tokens.push({ type: "PATTERN", index: i, value: pattern }); i = j; continue; }
|
这个逻辑也比较简单,就是获取括号之间的内容,顺便做一些非法判断,比如如果写了括号,但是正则长度为 0
的话那么报错
以及正则如果以 ?
开头,那么也报错
我们输入 /([0-9]{0,3})
,那么输出如下
对于 {}
这个匹配,现在我们只需要知道它做了个标记即可
经过上面的分析之后,我们就可以发现 lexer
只是标记分割类型而已,以及对类型本身进行一些简单的非法判断
对于某个类型的前后限制并没有判断,这是之后的 parse
函数的核心逻辑
parse
这个步骤会根据 lexer
生成的 LexToken
列表来生成 Token
列表, Token
的类型如下:
其中 string
很好理解,就是没有单纯的一个路径而已,和路径参数和正则都没有关系
而 Key
则是每个复杂路径的表示
parse
的精简代码如下
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
| export function parse(str: string, options: ParseOptions = {}): Token[] { const tokens = lexer(str); const { prefixes = "./" } = options; const defaultPattern = `[^${escapeString(options.delimiter || "/#?")}]+?`; const result: Token[] = []; let key = 0; let i = 0; let path = "";
const tryConsume = (type: LexToken["type"]): string | undefined => { };
const mustConsume = (type: LexToken["type"]): string => { };
const consumeText = (): string => { };
while (i < tokens.length) { }
return result; }
|
第一行,使用 lexer(path)
来获取 LexToken
列表,接下来就根据这个 LexToken
列表来生成 Token
列表
prefixes
表明了我们可以使用什么字符来分割路径,这里默认是 .
或者 /
defaultPattern
表明默认的路径参数匹配的规则,没错,默认路径参数如果不写正则的话,它是会默认设置一个正则的
我们执行 parse("/:foo")
,结果如下:
这里默认的正则起始就是 /[^/#?]+?/
其中前面三个函数 tryConsume
, mustConsume
,consumeText
1 2 3
| const tryConsume = (type: LexToken["type"]): string | undefined => { if (i < tokens.length && tokens[i].type === type) return tokens[i++].value; };
|
tryConsume
的意思就是我们尝试获取当前指向的 LexToken
,如果符合传入的 LexToken
类型,返回对应的 value
,并且索引 i
指向下一个 LexToken
,不符合则不进行任何操作
1 2 3 4 5 6
| const mustConsume = (type: LexToken["type"]): string => { const value = tryConsume(type); if (value !== undefined) return value; const { type: nextType, index } = tokens[i]; throw new TypeError(`Unexpected ${nextType} at ${index}, expected ${type}`); };
|
mustConsume
意味着当前指向的 LexToken
必须是特定 type
的 LexToken
,否则报错
这个函数主要用于匹配之前我们说过的 {}
自定义前后缀的逻辑,以及判断 END
类型的 LexToken
1 2 3 4 5 6 7 8
| const consumeText = (): string => { let result = ""; let value: string | undefined; while ((value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR"))) { result += value; } return result; };
|
consumeText
则是尝试拼接当前一段连续的 CHAR
或者 ESCAPED_CHAR
,即一段普通的字符串
接下来就是遍历 LexToken
列表的逻辑,这里我们拆分成三个部分会比较容易理解
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
| while (i < tokens.length) { const char = tryConsume("CHAR"); const name = tryConsume("NAME"); const pattern = tryConsume("PATTERN");
if (name || pattern) { continue; }
const value = char || tryConsume("ESCAPED_CHAR"); if (value) { path += value; continue; } if (path) { result.push(path); path = ""; }
const open = tryConsume("OPEN"); if (open) { continue; }
mustConsume("END"); }
|
对于第一部分,匹配的是可选的路径参数,以及可选的路径参数正则
没错,这两者都是可选的,也就是说,我们可以有如下的写法
/:foo
/(\\w+)
/:foo(\\w+)
这三者解析的结果如下
如果没写路径参数名,那么内部会赋予一个自增的索引,这个部分的代码如下
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
| if (name || pattern) { let prefix = char || "";
if (prefixes.indexOf(prefix) === -1) { path += prefix; prefix = ""; }
if (path) { result.push(path); path = ""; }
result.push({ name: name || key++, prefix, suffix: "", pattern: pattern || defaultPattern, modifier: tryConsume("MODIFIER") || "", }); continue; }
|
可以从上面看到,单是这一个大的 if
内就插入了两个 path
其实主要是为了区别前缀这个东西,对于 /foo-:bar
这种, /foo-
并不能称为 :bar
的前缀,而是一个单独的路径而已,而 :bar
也成为了一个单独的路径,此时它的前缀为空
而对于 /:bar
这种,由于 /
为默认的可选的前缀之一,所以可以成为 :bar
的前缀
1 2 3 4 5 6 7 8 9
| const value = char || tryConsume("ESCAPED_CHAR"); if (value) { path += value; continue; } if (path) { result.push(path); path = ""; }
|
第二部分就比较简单了,如果当前的 LexToken
是普通字符或者转义字符,那么直接累加到 path
上即可
注意,这里(第一个 if
内)并没有 push
操作,因为在 LexToken
中,每个普通字符都是一个 CHAR
类型的
只有当当前的 LexToken
不是 CHAR
或者 ESCAPED_CHAR
时,才会走到第二个 if
,把当前拼接的 path
放到结果数组中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const open = tryConsume("OPEN"); if (open) { const prefix = consumeText(); const name = tryConsume("NAME") || ""; const pattern = tryConsume("PATTERN") || ""; const suffix = consumeText();
mustConsume("CLOSE");
result.push({ name: name || (pattern ? key++ : ""), pattern: name && !pattern ? defaultPattern : pattern, prefix, suffix, modifier: tryConsume("MODIFIER") || "", }); continue; }
|
前面第二部分我们分析了当前的 LexToken
不是 CHAR
或者 ESCAPED_CHAR
那么当走到第三部分时,LexToken
的类型只能为 OPEN
或者 END
了
在 if
内的逻辑其实和第一个部分是差不多的,只不过多了前后缀的解析
对于 /{bar:foo-baz}
这样的路径,前后缀分别为 bar
和 -baz
,注意这里我们用 -
来表示后缀的开始
因为在 lexer
函数中我们说过,路径参数名会匹配 [0-9a-zA-z_]
的字符
如果你想说,不行,-
这个太丑了,不要这个,那么此时其实我们可以插入一个正则,这样就能正确的解析了,即 /{bar:foo(.*)baz}
tokensToRegexp
当我们得到 Token
列表之后, tokensToRegexp
就会把 Token
列表转为一个正则表达式
这里我们先关注这个函数的第三个参数,这是一个配置参数,控制一些变量,如下
sensitive
为 true
,表示区分大小写,内部使用正则的 i
标志
strict
为 true
,表示不允许可选的尾随分隔符,即 /foo
生成的正则无法匹配 /foo/
路径
start
和 end
为 true
,表示生成的正则限定了路径的开始和结束,内部使用正则的 ^
和 $
delimiter
,指定具名参数的分隔符,默认为 /
,?
和 #
endsWith
指定自定义的结束字符,优先级比 end = true
要高,内部使用正向先行断言,即 (?=endsWith)
encode
,指定字符串在插入正则前的需要做的处理,默认为 x => x
,即直接返回本身
prefixes
,指定自定义的前缀列表,默认为 .
和 /
这点我们在 parse
中见过相关的逻辑了
接下来我们来看源码,主要的逻辑就是一个遍历 Token
列表
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
| export function tokensToRegexp( tokens: Token[], keys?: Key[], options: TokensToRegexpOptions = {} ) { const { strict = false, start = true, end = true, encode = (x: string) => x, delimiter = "/#?", endsWith = "", } = options; const endsWithRe = `[${escapeString(endsWith)}]|$`; const delimiterRe = `[${escapeString(delimiter)}]`; let route = start ? "^" : ""; for (const token of tokens) { }
if (end) { } else { } return new RegExp(route, flags(options)); }
|
这里 for
内部就是生成 route
字符串的核心逻辑,然后最后使用 new RegExp(route, flags(options))
来生成一个正则并返回
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 69 70
| for (const token of tokens) { if (typeof token === "string") { route += escapeString(encode(token)); } else { const prefix = escapeString(encode(token.prefix)); const suffix = escapeString(encode(token.suffix));
if (token.pattern) { if (keys) keys.push(token); if (prefix || suffix) { if (token.modifier === "+" || token.modifier === "*") { const mod = token.modifier === "*" ? "?" : ""; route += `(?:${prefix}((?:${token.pattern})(?:${suffix}${prefix}(?:${token.pattern}))*)${suffix})${mod}`; } else { route += `(?:${prefix}(${token.pattern})${suffix})${token.modifier}`; } } else { if (token.modifier === "+" || token.modifier === "*") { route += `((?:${token.pattern})${token.modifier})`; } else { route += `(${token.pattern})${token.modifier}`; } } } else { route += `(?:${prefix}${suffix})${token.modifier}`; } } }
if (end) { if (!strict) route += `${delimiterRe}?`;
route += !options.endsWith ? "$" : `(?=${endsWithRe})`; } else { const endToken = tokens[tokens.length - 1]; const isEndDelimited = typeof endToken === "string" ? delimiterRe.indexOf(endToken[endToken.length - 1]) > -1 : endToken === undefined;
if (!strict) { route += `(?:${delimiterRe}(?=${endsWithRe}))?`; }
if (!isEndDelimited) { route += `(?=${delimiterRe}|${endsWithRe})`; } }
|
Vue Router@4 匹配算法
Vue Router@4
使用了自己写的一套路径转正则的算法,并且加入了路径的 Rank
分排行
上面我们说过这个灵感来自 express
,那么 express
使用了什么算法呢?翻开 express
的 package.json
,其实使用的就是上面我们说过的 path-to-regexp
不过 express
使用的是 0.1.x
的版本,那个版本只导出了 pathToRegexp
这个函数,而且实现上和目前的最新版本也有区别,这里就不展开了,有兴趣的可以去看看
在 Vue Router@3
中,对路径的匹配基于编写的顺序,即传入 routes
的数组内元素的顺序,它会按照顺序一个个匹配,如果匹配成功,那么直接返回对应的组件
当路由少的时候可能还行,但是一旦路由规则多了起来,有时候很容易出现写了路由没生效的情况了
而 Vue Router@4
引入了路径 Rank
分这一个特性,对于每个 path
路径,它都有一个 Rank
分,越详细越复杂的路径的 Rank
分就越高
即使你把这个复杂的路径写在了 Rank
分低于该路径的路径后面,也能够正确的进行匹配
对于路径转正则,其实 Vue-Router@3
和 Vue-Router@4
从方法上看并没有差很多,都是先转成 Token
,然后再根据 Token
来生成正则,所以这里我们就不展开了
这里主要讲讲 Vue-Router@4
的 Rank
分计算,源码中定义了一些得分规则
从图中可以看出,根路径得分 90
分,路径参数得分 20
分,以及一些比较低的参数的得分
通过这些得分,内部就能排序这些规则,从而以从得分高到得分低的顺序进行匹配
官方也给出了一个可以在线编写路由的测试网站,点我直达
这里我们使用一个例子,即 /:name(abc)
和 /:path
可以看出两者的得分是有区别的,指定了路径参数正则的路由得分要高,在右侧会比较 Rank
分从高到低排序
此时测试 /abc
那么 /:name(abc)
将会匹配 ,而 path
会被忽略,即使把它写在了 /:name(abc)
的前面
后记
是谁问的我这个问题呢,又或者说没人问过我这个问题
还要学习的东西还有很多,加油吧~