本文正在参加「金石计划」
代码未动,设计先行
一个 URL 对开发者来讲,最重要的就是 URL 中携带的参数,根据使用场景不同,一般有两个作用,以下面 URL 为例:
https://vueuse.org/?nickName=CondorHero&age=18
- 读取,获取 URL 参数做一些判断,比如
age > 18。 - 写入,更改 URL 中的参数,比如
nickName=Shavahn。
所以我们设计的 useUrlSearchParams 应该是这样的:
const params = useUrlSearchParams();
与此同时,URL 携带参数分为两种:
- search 模式,比如:
https://vueuse.org/?nickName=CondorHero&age=18 - hash 模式,比如:
https://vueuse.org/#nickName=CondorHero&age=18
对于 search 模式,浏览器提供了 URLSearchParams API,可以把 nickName=CondorHero&age=18 变成一个可操作的对象,但是对于 hash 浏览器则没有提供像 URLSearchParams 一样好用的 API 来解析 nickName=CondorHero&age=18,但我们可以仿照 URLSearchParams 自己实现。
现在给设计的 useUrlSearchParams 函数添加一个参数 mode,表示当前采用的模式,模仿 VueRouter 的 createWebHistory 路由,我们给 search 格式的路由命名为 history 同时作为默认值,可选 hash:
const params = useUrlSearchParams("history");
此时 useUrlSearchParams 返回的 params 应该是一个响应式的对象,以便开发者读取和写入 URL 的参数。
设计出大致轮廓就可以开干了,遇到问题再解决问题。
定义 useUrlSearchParams 函数
export function useUrlSearchParams(
mode: "history" | "hash" = "history"
) {}
mode 表示路由模式,其中默认值为 history。
响应式路由状态
定义 state,最终会把路由参数存在其中,以供外部读取和修改。
export function useUrlSearchParams(
mode: "history" | "hash" = "history"
) {
const state = reactive<Record<string, any | any[]>>({});
return state;
}
相信你注意到这里 state 的类型为:Record<string, any | any[]>;对象的值不仅可以单独存在,还可以为数组。
我们常见的 URL 都是一个字段对应一个基本类型,比如:
http://localhost:5173/?name=CondorHero&age=18&hobby=basketball
转成对象就是:
{
"name": "CondorHero",
"age": 18,
"hobby": "basketball",
}
人的爱好 hobby 可能不止一个,所以 URL 中的 hobby 还可以给出多个:
http://localhost:5173/?name=CondorHero&age=18&hobby=basketball&hobby=football
转成对象就是:
{
"name": "CondorHero",
"age": 18,
"hobby": [ "basketball", "football" ],
}
利用 location 获取 URL 参数
const getRawParams = () => {
if (mode === "history") {
return window.location.search;
} else {
return window.location.hash.replace(/^#/, "?");
}
}
- window.location.search 获取的格式为:
?name=CondotHero&age=18。 - window.location.hash 获取的格式为:
#name=CondotHero&age=18。
之所以要使用 replace 把 # 替换掉,是因为 URLSearchParams 无法解析它。
序列化 getRawParams
使用 URLSearchParams 把 getRawParams 读取的值变成编程可维护的状态。
const read = () => {
return new URLSearchParams(getRawParams());
}
更新 state
在 return state 之前,更新 state 整个读取过程就完成了。
updateState(read());
return state;
updateState 的逻辑如下:
const updateState = (params: URLSearchParams) => {
for (const key of params.keys()) {
const paramsForKey = params.getAll(key)
state[key] = paramsForKey.length > 1
? paramsForKey
: (params.get(key) ?? "")
}
}
这里唯一需要注意的是判断值,多个就作为数组,一个就不是数组。getAll 则可以获取 key 对应的所有得知,get 只会获取第一个出现的值。
演示值得读取
vue 组件中引入 useUrlSearchParams:
const params = useUrlSearchParams("history");
URL 为 http://localhost:5173/?name=CondorHero&age=18 显示结果如下:
URL 的写入
const params = useUrlSearchParams("history");
useUrlSearchParams 返回的 params 现在能够读取,作为一个响应式对象,它应该还支持写入,比如:
params.name = Shavahn;
params.age = 19;
写入的实现也就有思路了,只需要用 watch 监听 state 的改变,然后把 state 的值重新写入 URL 中。
watch(
state,
() => {
const params = new URLSearchParams();
Object.keys(state).forEach((key) => {
const mapEntry = state[key]
if (Array.isArray(mapEntry)){
mapEntry.forEach(value => params.append(key, value))
} else {
params.set(key, mapEntry)
}
})
write(params)
},
{ deep: true },
);
这里需要注意的一点路由参数如果是多个,也就是数组需要 append 添加,如果用 set 会覆盖。
更新 URL 逻辑放入到了 write 函数。
更新 URL
注意这里用的是 replaceState 表示替换当前路由,一般来讲,你只有在进入页面的时候等极少情况下修改 searchparams,大多数时候使用 vue-router 或者浏览器本身提供的路由就行了。
所以这里用 replaceState 重写当前路由是合适的。
const write = (params: URLSearchParams) => {
window.history.replaceState(
window.history.state,
window.document.title,
window.location.pathname + constructQuery(params),
);
}
最后利用 constructQuery 拼出正确 URL:
const constructQuery = (params: URLSearchParams) => {
const stringified = params.toString()
if (mode === "history") {
return `${stringified ? `?${stringified}` : ""}${window.location.hash}`
}
return `${window.location.search}${stringified ? `#${stringified}` : ""}`
}
演示写入
添加 popstate 事件
现在假如页面有一个标签:
<a href="#tab-one">Open Link: #tab-one</a>
点击之后页面肯定从 http://localhost:5173/#age=90&name=Jack 跳转到 http://localhost:5173/#tab-one,这个时候 params 也应该立即变化,像这种路由前进后退,监听它变化的事件就是 popstate:
const popstateEvent = () => {
updateState(read());
}
onMounted(() => {
window.addEventListener("popstate", popstateEvent)
})
onUnmounted(() => { window.reportError(popstateEvent) })
这个时候需要修改下 updateState 函数里面的逻辑,因为 state 对象上现在还有 name 和 age 字段,而新的 URL 已经没有了,所以再重新更新 state 的时候,应该把不存在的 key 删除:
const updateState = (params: URLSearchParams) => {
const unUsedKeys = new Set(Object.keys(state));
for (const key of params.keys()) {
const paramsForKey = params.getAll(key)
state[key] = paramsForKey.length > 1
? paramsForKey
: (params.get(key) ?? "");
unUsedKeys.delete(key);
}
Array.from(unUsedKeys).forEach(key => delete state[key]);
}
点击之后路由跳转之后的演示:
添加默认值
可以利用 useUrlSearchParams 的第二个参数添加更多自定义行为,比如,进入页面默认添加些路由参数。
interface UseUrlSearchParamsOptions {
initialValue?: Record<string, any | any[]>
}
export function useUrlSearchParams(
mode: "history" | "hash" = "history",
options: UseUrlSearchParamsOptions = {}
) {
const { initialValue } = options;
}
接下来更改 updateState(read()) 的逻辑:
const initial = read();
if (initial.toString().length) {
updateState(initial)
} else {
Object.assign(state, initialValue)
}
如果路由有参数就不需要设置,否则使用默认参数。
最终效果
所有代码示例在此:
补充
VueUse 提供的 UrlSearchParams 和本例的 mode 有稍稍不同,VueUse 的 UrlSearchParams 支持三种模式:history、hash、hash-params。本例实现的 history 和 VueUse 是相同的,但 hash 模式实际上对应 VueUse 的 hash-params 模式。VueUse 的 hash,实际上是 history 和 hash-params 结合:
http://localhost:5173/#?age=90&name=Jack
如果你学会了本例讲解的两种模式,相信这个模式你也能轻松实现。