前端路由状态保持
前言
在日常开发中我们经常会遇到这样一种需求: 用户A在对页面做了一些定制化的筛选后, 将网页链接分享给用户B, 期望用户B打开网页后页面的筛选态保持和A一样, 这就是路由状态保持.
要想实现这一功能, 我们就只能在页面路由链接上做一些处理, 将筛选项这种页面的状态维护在链接中, 用户在访问链接的时候页面对其做一个状态解析和更新, 达到我们期望的效果. 实现这种功能有大致三种方式:
参数拼接
- 这种方式比较直观, 将各种状态直接展示出来, 如
https://www.xxx.com/?sort=true&tagId=123&category=456.
- 这种方式比较直观, 将各种状态直接展示出来, 如
字符解析
这种方式就有点黑盒, 路径可能为
https://www.xxx.com/0-0-0-123-0, 各个数字/字符的含义只有页面应用自己知道, 所有的逻辑都不外投.短链实现
- 这种方式是把当前的页面状态编码到一个链接中(十分简短), 然后页面进行一个解析, 相比于上面的路由解析更黑盒, 路由可能是
https://www.xxx.com/dsfjsdf这种, 当然也可以借助nginx/后端服务去做一个路由匹配, 不用页面解析.
- 这种方式是把当前的页面状态编码到一个链接中(十分简短), 然后页面进行一个解析, 相比于上面的路由解析更黑盒, 路由可能是
本篇文章主要介绍 字符解析 部分(TS的实现过程为本文重点) , 其中包括一些ts的定义, 推断流程等, 学习高级ts写法, 其次是拓展一些 锻炼实现 的相关知识, 参数拼接 则只做一个简单介绍.
下面就是三种实现方式的详细讲解.
2. 参数拼接
这种方式无疑是最直观的, 可读性高,所有的参数均放在连接中, 研发省心, 用户也知道路径里都是什么, 但是这种方式只适合简单的情况.
试想一下, 如果一个页面有3+ 个筛选项/状态, 那么路径就有可能为 https://www.xxx.com/?state=hot&color=black&age=18 , 此外, 如果遇到筛选项支持多选的话, 路径的维护更是一个灾难.
这种方式的实现也比较简单, 伪代码如下:
在这个过程中没有很复杂的地方, 唯一要注意的可能就是参数拆分的处理, 比如空态, 错误态等边缘情况的处理, 其他如路径生成就使用固定的参数去进行匹配赋值即可.
3. 字符解析
这种方式相比于 参数拼接 不太直观, 可读性不高, 解析路径比较复杂, 对于研发有一定的要求, 需要处理好路径解析算法, 但是它的好处也是很大的, 上一节的 https://www.xxx.com/?state=hot&color=black&age=18 处理后可能就变成了 https://www.xxx.com/1-2-3 , 对于可多选的筛选项也能很好的支持, 如 https://www.xxx.com/1_7-2-3 , 不同筛选项使用 - 进行分割, 同一个筛选项的不同状态使用 _ 进行拼接, 这样很好的解决了链接过长, 筛选项过多的问题.
这种方式的伪代码和 参数拼接 也是一样的, 但不同的是字符解析生成更为复杂, 要做不同情况的处理. 而如何解析这些字符, 就是写这篇文章的主要原因.
我们先从简单的路径解析说起, 以 https://www.xxx.com/pages/1-1-1-1 为例.
3.1 路径解析
首先, 我们需要将路径中的 1-1-1-1 提取出来, 这部分可以维护一个字符/正则数组来实现, 也可以单页面单独进行处理. 如果选择以正则map来进行提取, 我们需要维护一个科学的正则数组, 遍历当前路径, 当匹配到时返回匹配结果, 如果知道最后也没有匹配到, 返回页面丢失或其他兜底逻辑.
// 路径匹配解析函数matchPath
// 该函数是react-router内置函数
// Performs pattern matching on a URL pathname and returns information about the match.
// 具体实现
const regs = ['/pages/:searchSegments']
const match = regs.some(reg => {
return matchPath(reg, window.location.pathname)
});
if (match) {
const [state = 0, color = 0, age = 0, height = 0] = match.params.searchSegments.split('-')
// ...做后续业务处理
} else {
// ...兜底逻辑
}3.1 路径生成
除了上面从路径中提取参数, 我们还有另一个比较艰巨的任务: 生成相应路径.
这个任务听起来好像也不是很复杂, 我们只需要做一些路径的拼接就可以了, 但是这是基于 js 的前提下, 我们在得到便利性的同时, 也牺牲了可靠性, 所以我们需要借助 ts 来定义路径生成类型, 做类型推断, 提高这一过程的可靠性. 下面我们会先以 js 为例实现一个简单的路径生成, 然后再使用 ts 来生成一个可靠的路径, 最后做一个简单的对比.
JS简单处理
首先我们定义路径为: https://www.xxx.com/[state]-[color]-[age]-[height]
路径生成
const pathname = '1-2-1-2'
let [state = 0, color = 0, age = 0, height = 0] = pathname.split('-')
// 假如更新了state
state = 2
const newPath = [state, color, age, height].join('-')可以看到这种方式也能够实现我们的需求, 虽然可能状态多的时候需要进行许多case的判断, 但如果是只有一个页面, 这就够使用了. 但这样的实现明显不够有保障性, 生成的规则比较随意, 没有统一的约束. 下面看下使用TS实现的效果.
TS复杂处理
在这一部分代码比较多, 不但包括一些逻辑生成还有一大部分的类型约束, 整体流程可以分为下面几个步骤:
- 通过ts进行类型解析
- 通过schema和对应路径map进行类型约束和确定
- 通过生成函数进行路径生成
其各步骤对应的代码如下
- ts类型解析
首先看类型的定义, 这里是组内大佬些的, 我自己觉得是有些复杂, 因为我读起来太难懂了 [捂脸]. 里面一个主要的知识点是ts的infer 推断, 这个也比较难懂. 因此我会把一些自己的理解直接放到下面的代码里便于查看.
/**
* 默认允许参数类型
* 这里就类似于约定 state值的类型, 如state = 0 | '0' ...
*/
type ParamValue = string | number | string[] | number[];
/**
* 这里首先定义入参泛型 T 只能为字符类型(该类型有很多子类), U默认类型为参数类型
* 其次使用infer关键字进行类型推断, 在推断的过程中还混杂了字面量类型
* 也即infer 定义了一个类型Param, 如果T是可选字符, 那么该类型就可以确定为传入字符的可选类型, 否则就原样返回
* 可以把infer理解为一个函数,
* 如ExtractRouteOptionalParam<'demo'>, 那么得到的类型就是 { demo: U }
* ExtractRouteOptionalParam<'demo?'>, 那么得到的类型就是 { demo?: U }
*/
type ExtractRouteOptionalParam<T extends string, U = ParamValue> = T extends `${infer Param}?`
? { [k in Param]?: U }
: { [k in T]: U };
/**
* 这里的逻辑和上面类似, 但是对不同的入参做了switch以及递归解析
* 第一个判断, 如果是可选类型那就全部定义为可选参数
* 第二个判断, 如果入参类型为 模板字面量[param1]:[param2]/[param3]类型, 则返回ExtractRouteOptionalParam<Param, U> & ExtractRouteParams<Rest, U>
* 如入参为'home:demo/age' 则ExtractRouteOptionalParam<Param, U> & ExtractRouteParams<Rest, U> = { demo: U }
* 第三个判断, 如果入参类型为 模板字面量[param1]:[param2]-[param3]类型, 则返回ExtractRouteOptionalParam<Param, U> & ExtractRouteParams<Rest, U>
* 如入参为'home:demo-age' 则ExtractRouteOptionalParam<Param, U> & ExtractRouteParams<Rest, U> = { demo: U }
* 最后一个判断, 如果入参类型为 模板字面量[param1]:[param2]类型, 则返回ExtractRouteOptionalParam<Param, U>
* 如入参为'home:demo-age-color' 则ExtractRouteOptionalParam<Param, U> = { demo: U }
* 以我们定义的路由 [state]-[color]-[age]-[height]为例
* 入参为'/home/:state-:color-:age-:height' 最后得到的类型就是 { state: U; color: U; age: U; height: U }
* 入参为'/home/:state?-:color-:age-:height' 最后得到的类型就是 { state?: U; color: U; age: U; height: U }
*/
type ExtractRouteParams<T extends string, U = ParamValue> = T extends `${infer Key}?`
? { [k in Key]?: U }
: // eslint-disable-next-line @typescript-eslint/no-unused-vars
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? ExtractRouteOptionalParam<Param, U> & ExtractRouteParams<Rest, U>
: // eslint-disable-next-line @typescript-eslint/no-unused-vars
T extends `${infer _Start}:${infer Param}-${infer Rest}`
? ExtractRouteOptionalParam<Param, U> & ExtractRouteParams<Rest, U>
: // eslint-disable-next-line @typescript-eslint/no-unused-vars
T extends `${infer _Start}:${infer Param}`
? ExtractRouteOptionalParam<Param, U>
: {};
/**
* 上面的类型主要是做公共类型的约束, 本类型将所有的选项配置为可选类型, 同时可以通过泛型ActualParam指定/改变参数的默认类型
* 以'/home/:state-:color-:age-:height'为例
* 如果入参为 ExtractRouteOptionalParamWithSchemaPrams<'/home/:state', {}>, 返回为 { state?: ParamValue }
* 如果入参为 ExtractRouteOptionalParamWithSchemaPrams<'/home/:state', { state: boolean }>, 返回为 { state?: boolean }
* 可以看到第二个可以改变默认的参数类型
*/
type ExtractRouteOptionalParamWithSchemaPrams<T extends string, ActualParam, SchemaParam = ExtractRouteParams<T>> = {
[key in keyof SchemaParam]?: key extends keyof ActualParam ? ActualParam[key] : SchemaParam[key];
};- 通过schema和对应路径map进行类型约束和确定
/**
* 路由模板
* 定义该路由的参数有open, 有page, 以及他们两个的类型和位置
*/
interface LinkSchema {
/* 文章详情页 */
'/home/:state-:color-:age-:height': {
params: {
state: string;
color: string;
age: number;
height: number;
};
};
}
// 通过该map可以匹配到上面interface内具体的schema参数
const LinkMap = {
HOME: '/home/:state-:color-:age-:height'
}- 路径生成函数
/**
* 基于路径生成相应规则
* 正规规则中兼顾 base的样式处理
* 暂不考虑解析异常的情况
* 其中可以对特定的key做页数处理, 如对汉字等字符进行base62编码
* @param path
* @param iParams
*/
const generatePath = (path: string, iParams: { [key: string]: ParamValue }) => {
let params = iParams;
if (params === void 0) {
params = {};
}
const iPath = path.split('?')[0]!;
return iPath
.replace(/:(\w+)/g, (_, key: string) => {
let value = params[key];
if (value === undefined) {
return '0';
} else if (typeof value === 'number') {
value = value.toString();
} else if (Array.isArray(value)) {
const newValue = value.map((i) => {
if (isObject(i)) {
/**
* 对用 array_object_array风格
* 只考虑[key: string | number]: number[]风格
*/
const v = i as { [key: string | number]: number[] };
const k = Object.keys(v)[0]!;
return [k, v[k]!.join(',')].join('.');
}
return i;
});
value = !!newValue.length ? newValue.join('_') : '0';
}
// base62编码, 兼容路径展示
if (key === 'xxx') {
value = !!value.length ? base62x.encode(value) : '0';
}
return (value as string) ?? '0';
})
.replace(/\/*\*$/, (_) => (params['*'] == null ? '' : (params['*'] as string).replace(/^\/*/, '/')));
};
/**
* @deprecated
* 给予链接生成
* 如果当前link在schema中的参数定义和函数形参params的定义匹配, 那么使用形参params的类型
* 如 genLink('/home/:state-:color-:age-:height', { color: 'black' }) = '/home/0-red-0-0'
* @param link: 该路由的参数模板, 如'/home/:state-:color-:age-:height'
* @param params: 生成该路由所需的参数, 如 { color: 'red' }
* @param query: 也是生成该路由所需的参数, 如 { color: 'red' }
*/
genLink<Link extends keyof LinkSchema>(
link: Link,
params: LinkSchema[Link] extends { params: { [key: string]: any } }
? ExtractRouteOptionalParamWithSchemaPrams<Link, LinkSchema[Link]['params']>
: ExtractRouteParams<Link>,
query?: LinkSchema[Link] extends { query: { [key: string]: any } } ? LinkSchema[Link]['query'] : {}
) {
let pathname = generatePath(link, params as { [key: string]: string });
const search = qs.stringify(pickBy(query ?? {}));
if (search) {
pathname = `${pathname}?${search}`;
}
return pathname;
}具体使用
// 具体内容参考上面代码, 这里不在重复写了
const genLink = (link: any, params: any, query?: any) => {}
// 生成链接
const link = genLink(LinkMap.ARTICLE, {state: 1, height: 3})
conole.log(link) // /home/1-0-0-3可以看到, 使用 TS 来进行实现后虽然整体代码多了许多, 逻辑也复杂了许多, 但是也更有保障性了. 开发者只需要定义路由的schema 以及对应map即可, 生成路径的时候把最新的参数传进去即可, 类型更有约束性. 如果增加状态, 也是只需要修改固定配置项即可, 而且对于多页面/场景的处理也更有组织性, 相比于 JS 规范不少.
这种实现对于调用者来说心理负担会比较少, 正常配置不会出现问题. 不过出现问题排查起来会比较困难, 因为你首先需要这里的逻辑搞清楚...
4. 短链实现
这种实现方式是最黑盒的, 可能分享给其他人的就一段不明所以的链接, 如 https://07.mmdhjf.t 这种, 用户打开网站后, Nginx/后端服务 会将流量转发到真实地址, 这种更适合于活动页的分享, 不适合用户侧自主分享使用, 因此本文也只是简单提及.
主流的有三种实现方案
- 后端读数据库,后端 302
- Nginx 302
- openresty -> 直接读 redis 302, 如果找不到时去后端
4.1 后端302
这里使用 express 简单实现下后端的
const express = require('express');
const fs = require("node:fs");
const path = require("path");
const app = express();
const FAKE_DB_MAP = {
'/short': '/long-link'
}
app.get('/', (req, res) => {
res.end(fs.readFileSync(path.join(__dirname, './origin.html')))
})
app.get('/short', (req, res) => {
res.writeHead(302, {
Location: FAKE_DB_MAP['/short'],
});
res.end();
})
app.get('/long-link', (req, res) => {
res.end(fs.readFileSync(path.join(__dirname, './long.html')))
})
app.listen(4000, () => {
console.log('success')
})实现后的效果为, 访问 http:localhost:4000/short 自动跳转到 http://localhost:4000/long-link

4.2 Nginx 302
- 手动实现
Nginx 的实现也比较简单(当然这里只是一个简单的演示, 没有生成算法解析算法, 复杂的重定向要配合lua进行使用)
location /short {
rewrite ^/(.*) http://localhost:4000/long-link redirect;
}- Openresty 实现
这种实现方式也是基于Nginx , 但是Openresty 比原生的Nginx 功能更丰富一些, 可以编写 Lua 脚本 + 数据库进行复杂处理, 具体可以参考其官网介绍: https://openresty.org/en/getting-started.html
由于短时间没有这个需求, 而且还要下载 Openresty 进行配置调试, 所以这里就不叙述了.
5. 总结
本文从业务场景出发, 介绍了三种实现状态保持的解决方案, 三种方案各有优略. 在日常的业务开发过程中方案一是最简单的; 方案二如果使用js复杂度容易上手, 使用ts的规范性能得到保障, 但是入手有点难; 至于方案三则更多属于 企业 -> 用户 引导点击, 以及活动分享链路.
最重点的内容就是方案二的ts推导, 这种ts的实践方式还是值得学习借鉴的.
