Vue-Router是如何解析URL路由参数的?

395 篇文章 34 订阅

看过Vue-Router源码的小伙伴都值,Vue-Router解析路径参数时是借助path-to-regexp库将参数解析成对应的正则表达式的。接下来我们将基于6.2.0版本的path-to-regexp库介绍下该库的基本使用和背后的实现原理。更有意思的path-to-regexp库虽小,但是背后实现了一个非图灵完备的词法分析,好sao啊,我好喜欢…

废话不多说,直接上车!!!

下面我们看下path-to-regexp的基本使用,它的主要作用是将字符串路径(例如/user/:name)转换成对应的正则表达式,使用示例如下所示:

const { pathToRegexp, parse, compile } = require('path-to-regexp');

const url = '/user/:id';

const keys = [];
const regexp = pathToRegexp(url, keys);

// /^\/user(?:\/([^\/#\?]+?))[\/#\?]?$/i
console.log(regexp);

/**
 * [
 *  {
 *    name: 'id',
 *    prefix: '/',
 *    suffix: '',
 *    pattern: '[^\\/#\\?]+?',
 *    modifier: ''
 *  }
 * ]
 */
console.log(keys);

// ['/user/10086', '10086', index: 0, input: '/user/10086', groups: undefined]
console.log(regexp.exec('/user/10086'));

// null
console.log(regexp.exec('/notuser/10086'));

const tokens = parse(url);

/**
 * [
 *  '/user',
 *  {
 *    name: 'id',
 *    prefix: '/',
 *    suffix: '',
 *    pattern: '[^\\/#\\?]+?',
 *    modifier: ''
 *  }
 * ]
 */
console.log(tokens);

const toPath = compile(url, {
  encode: encodeURIComponent,
});

const path1 = toPath({
  id: 123,
});

// /user/123
console.log(path1);
复制代码

基本结构介绍
打开源码文件我们可以看到,该库的所有实现都在src文件夹下的index.ts文件中,如下图所示:

在这里插入图片描述

其中index.ts中主要实现几个函数作为API对外暴露:

export function parse() {}

export function compile<P extends object = object>() {}

export function tokensToFunction<P extends object = object>() {}

export function match<P extends object = object>() {}

export function regexpToFunction<P extends object = object>() {}

export function regexpToFunction<P extends object = object>() {}

export function tokensToRegexp() {}

export function pathToRegexp() {}
复制代码

下面我们看下pathToRegexp函数的实现过程。

pathToRegexp实现
pathToRegexp函数是我们主要使用的,将路径字符串转换成正则对象的实现:

/**
 * 格式化给定的字符串并返回一个正则表达式
 *
 * An empty array can be passed in for the keys, which will hold the
 * placeholder key descriptions. For example, using `/user/:id`, `keys` will
 * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`.
 */
export function pathToRegexp(
  path: Path,
  keys?: Key[],
  options?: TokensToRegexpOptions & ParseOptions
) {
  /**
   * 一个策略组,根据传入的path参数类型,调用不同的实现
   *  - path是正则对象,则调用regexpToRegexp转换
   *  - path是数组,则调用arrayToRegexp转换
   *  - path是字符串,则调用stringToRegexp转换
   */
  if (path instanceof RegExp) return regexpToRegexp(path, keys);
  if (Array.isArray(path)) return arrayToRegexp(path, keys, options);
  return stringToRegexp(path, keys, options);
}
复制代码

stringToRegexp的实现,该方法是对路径字符串的转换逻辑:

/**
 * 从一个字符串输入创建路径正则对象
 */
function stringToRegexp(
  path: string,
  keys?: Key[],
  options?: TokensToRegexpOptions & ParseOptions
) {
  // 先使用parse处理成需要的tokens
  // 然后调用tokensToRegexp将tokens转换成正则对象
  return tokensToRegexp(parse(path, options), keys, options);
}
复制代码

parse实现
parse的实现,先调用lexer对字符串进行词法分析,然后进行语法分析,将语法分析得到的结果输出返回:

/**
 * 分析原始标记的字符串
 */
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 => {
    if (i < tokens.length && tokens[i].type === type) return tokens[i++].value;
  };

  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}`);
  };

  const consumeText = (): string => {
    let result = "";
    let value: string | undefined;
    // tslint:disable-next-line
    // 将多个CHAR或者ESCAPED_CHAR类型的token组成一个连续的字符串
    while ((value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR"))) {
      result += value;
    }
    return result;
  };

  // 将得词法分析到的tokens,进行语法分析
  while (i < tokens.length) {
    const char = tryConsume("CHAR");
    const name = tryConsume("NAME");
    const pattern = tryConsume("PATTERN");

    // 处理NAME或PATTERN类型的token
    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;
    }

    // 处理CHAR或ESCAPED_CHAR类型的token
    const value = char || tryConsume("ESCAPED_CHAR");
    // 一直匹配到非(CHAR或ESCAPED_CHAR)类型的token停止
    if (value) {
      path += value;
      continue;
    }
    // 将匹配到的结果添加到解析结果中,并且置空本次的匹配结果
    if (path) {
      result.push(path);
      path = "";
    }

    // 处理OPEN和CLOSE类型的token
    // 例如处理 const regexp = pathToRegexp("/:attr1?{-:attr2}?");
    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;
    }

    mustConsume("END");
  }

  return result;
}
复制代码

可以看到parse的过程就是先通过lexer进行词法分析拿到所有的tokens,然后就是消费tokens。消费的过程就是迭代所有的tokens,消费的依据就是该库期望对外暴露的语法规则。

lexer简易词法分析实现
lexer词法分析主要作用就是按规则分割出tokens,具体实现如下:

/**
 * Tokenize input string.
 * 词法分割
 */
function lexer(str: string): LexToken[] {
  const tokens: LexToken[] = [];
  let i = 0;

  // 依次迭代每一个字符
  while (i < str.length) {
    // 获取当前字符
    const char = str[i];

    // 如果是星号、加号、问号,则分割为MODIFIER类型的token
    if (char === "*" || char === "+" || char === "?") {
      tokens.push({ type: "MODIFIER", index: i, value: str[i++] });
      continue;
    }

    // 如果是\符号,则分割为ESCAPED_CHAR类型的token
    if (char === "\\") {
      tokens.push({ type: "ESCAPED_CHAR", index: i++, value: str[i++] });
      continue;
    }

    // 如果是左花括号,则分割为OPEN类型的token
    if (char === "{") {
      tokens.push({ type: "OPEN", index: i, value: str[i++] });
      continue;
    }

    // 如果是右花括号,则分割为CLOSE类型的token
    if (char === "}") {
      tokens.push({ type: "CLOSE", index: i, value: str[i++] });
      continue;
    }

    // 如果是冒号,则继续分割冒号后面的字符串
    if (char === ":") {
      let name = "";
      let j = i + 1;

      // 通过字符对应的Unicode值匹配所有的数字、英文大小写、连字符
      // 通过正则匹配也可以,/^[0-9a-zA-Z-]$/,经测试性能并不比Unicode判断差
      while (j < str.length) {
        const code = str.charCodeAt(j);

        if (
          // `0-9`
          (code >= 48 && code <= 57) ||
          // `A-Z`
          (code >= 65 && code <= 90) ||
          // `a-z`
          (code >= 97 && code <= 122) ||
          // `_`
          code === 95
        ) {
          name += str[j++];
          continue;
        }

        break;
      }

      if (!name) throw new TypeError(`Missing parameter name at ${i}`);

      // 将冒号后面匹配到的符合规则的字符串,分割为NAME类型的token
      tokens.push({ type: "NAME", index: i, value: name });
      i = j;
      continue;
    }

    // 如果当前字符是左小括号
    if (char === "(") {
      /**
       * count 左右小括号的计数,是根据栈来判断左右小括号是否匹配的平替方案
       *  - 遇到左括号加一
       *  - 遇到右括号减一
       * 最终根据count的值判断左右小括号是否正确匹配
       */
      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--;
          // 如果已完成所有左右小括号的匹配,则停止当前token字符匹配
          if (count === 0) {
            j++;
            break;
          }
        } else if (str[j] === "(") {
          count++;
          // (user(?xxx)) 要求捕获组必须要问号开头
          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}`);

      // 将(pattern)内的pattern部分分割为PATTERN类型的token
      tokens.push({ type: "PATTERN", index: i, value: pattern });
      i = j;
      continue;
    }

    // 其他字符分割为类型为CHAR的token
    tokens.push({ type: "CHAR", index: i, value: str[i++] });
  }

  // 最后添加一个类型为END的token
  tokens.push({ type: "END", index: i, value: "" });

  return tokens;
}
复制代码

lexer是本次本库的核心实现,主要逻辑如下:

逐个遍历字符串的字符
给不同的字符打上不同的标记,例如MODIFIER、CHAR等
根据不同的字符进行不同规则的匹配
将每个规则匹配到的字符数据存入tokens数组
将结果返回
然后这里需要注意的是:

这里的词法分割并没有使用有限状态机,而是遍历后就直接消费了。
匹配冒号后面的字符串时用的Unicode值比对方法,平替成正则/1$/也是可以的,经个人测试,性能并不比Unicode判断差
lexer中判断左右小括号是否正确匹配的逻辑,直接使用的count计数来判断,同样的实现也有栈的方案。
i++自增自减的逻辑,加号在前在后的区别,在普通使用中没有任何区别,但是在赋值时则是加号在前先自增再赋值,加号在后先赋值再自增
arrayToRegexp实现
pathToRegexp方法中如果path是数组则调用arrayToRegexp来具体实现,其作用是传入数组时生成的正则是可以匹配多个逻辑,也就是或的意思:

/**
 * 将数组转换成正则对象
 */
function arrayToRegexp(
  paths: Array<string | RegExp>,
  keys?: Key[],
  options?: TokensToRegexpOptions & ParseOptions
): RegExp {
  const parts = paths.map(path => pathToRegexp(path, keys, options).source);
  return new RegExp(`(?:${parts.join("|")})`, flags(options));
}
复制代码

pathToRegexp实现逻辑如下:

遍历所有的path并调用pathToRegexp获取path对应的正则表达式文本
将所有的文本用|拼接起来
重新调用new RegExp生成新的正则对象
这里需要注意的是:

正则中小括号()表示捕获组,就是将匹配到的内容存储起来以供使用
(?:)表示非捕获组,即只进行匹配,不对匹配的结果进行存储
compile原理
compile作用主要是给路径字符串填充数据,例如:

const toPath = compile("/user/:id", { encode: encodeURIComponent });

toPath({ id: 123 }); //=> "/user/123"
复制代码

其实现如下:

/**
 * Compile a string to a template function for the path.
 * 给路径字符串的参数填充数据
 */
export function compile<P extends object = object>(
  str: string,
  options?: ParseOptions & TokensToFunctionOptions
) {
  // 先调用parse函数解析路径字符串
  // 再调用tokensToFunction进行字符串填充
  return tokensToFunction<P>(parse(str, options), options);
}
复制代码

tokensToFunction的实现:

/**
 * Expose a method for transforming tokens into the path function.
 */
export function tokensToFunction<P extends object = object>(
  tokens: Token[],
  options: TokensToFunctionOptions = {}
): PathFunction<P> {
  const reFlags = flags(options);
  const { encode = (x: string) => x, validate = true } = options;

  // Compile all the tokens into regexps.
  // 该方法主要作用是根据token创建正则对象
  // 并且在用户指定要对传入数据进行校验时进行调用校验
  const matches = tokens.map(token => {
    if (typeof token === "object") {
      // 创建非捕获的正则表达式
      return new RegExp(`^(?:${token.pattern})$`, reFlags);
    }
  });

  // 返回一个函数,在用户调用时将数据填充还原到路径字符串中
  return (data: Record<string, any> | null | undefined) => {
    let path = "";

    /**
     * 依次遍历所有token,
     * 如果用户有传入相同key的数据,则进行填充
     */
    for (let i = 0; i < tokens.length; i++) {
      const token = tokens[i];

      if (typeof token === "string") {
        path += token;
        continue;
      }

      const value = data ? data[token.name] : undefined;
      const optional = token.modifier === "?" || token.modifier === "*";
      const repeat = token.modifier === "*" || token.modifier === "+";

      // 如果传入的是数据是数组则进行平铺
      if (Array.isArray(value)) {
        if (!repeat) {
          throw new TypeError(
            `Expected "${token.name}" to not repeat, but got an array`
          );
        }

        if (value.length === 0) {
          if (optional) continue;

          throw new TypeError(`Expected "${token.name}" to not be empty`);
        }

        for (let j = 0; j < value.length; j++) {
          const segment = encode(value[j], token);

          // 如果用户设置了校验传入的数据,则进行正则校验
          if (validate && !(matches[i] as RegExp).test(segment)) {
            throw new TypeError(
              `Expected all "${token.name}" to match "${token.pattern}", but got "${segment}"`
            );
          }

          // 将数据还原填充到url中
          path += token.prefix + segment + token.suffix;
        }

        continue;
      }

      if (typeof value === "string" || typeof value === "number") {
        const segment = encode(String(value), token);

        if (validate && !(matches[i] as RegExp).test(segment)) {
          throw new TypeError(
            `Expected "${token.name}" to match "${token.pattern}", but got "${segment}"`
          );
        }

        // 将数据还原填充到path内
        path += token.prefix + segment + token.suffix;
        continue;
      }

      if (optional) continue;

      const typeOfMessage = repeat ? "an array" : "a string";
      throw new TypeError(`Expected "${token.name}" to be ${typeOfMessage}`);
    }

    return path;
  };
}
复制代码

如果去掉所有的校验等逻辑,核心逻辑就是:

依次遍历所有token
如果用户有传入相同key的数据,则进行字符串填充
最终将填充拼接的字符串返回
最后
如果你觉得此文对你有一丁点帮助,点个赞。或者可以加入我的开发交流群:1025263163相互学习,我们会有专业的技术答疑解惑

如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点star:http://github.crmeb.net/u/defu不胜感激 !

PHP学习手册:https://doc.crmeb.com
技术交流论坛:https://q.crmeb.com


  1. 0-9a-zA-Z- ↩︎

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值