跳到主要内容

解析 URL 参数

问题

实现一个函数,解析 URL 中的 query string 参数,返回一个对象。

答案

URL 参数解析是前端常见需求,需要处理编码、数组、嵌套对象等情况。


基础实现

function parseQueryString(url: string): Record<string, string> {
const result: Record<string, string> = {};

// 获取 ? 后面的部分
const queryString = url.split('?')[1];
if (!queryString) return result;

// 去掉 hash
const query = queryString.split('#')[0];

// 解析参数
query.split('&').forEach((pair) => {
const [key, value = ''] = pair.split('=');
if (key) {
result[decodeURIComponent(key)] = decodeURIComponent(value);
}
});

return result;
}

// 测试
const url = 'https://example.com?name=John&age=25&city=%E5%8C%97%E4%BA%AC';
console.log(parseQueryString(url));
// { name: 'John', age: '25', city: '北京' }

支持数组参数

type ParsedQuery = Record<string, string | string[]>;

function parseQueryWithArray(url: string): ParsedQuery {
const result: ParsedQuery = {};

const queryString = url.split('?')[1]?.split('#')[0];
if (!queryString) return result;

queryString.split('&').forEach((pair) => {
const [rawKey, rawValue = ''] = pair.split('=');
if (!rawKey) return;

const key = decodeURIComponent(rawKey);
const value = decodeURIComponent(rawValue);

// 处理数组格式: key[] 或重复的 key
const arrayKey = key.endsWith('[]') ? key.slice(0, -2) : key;

if (key.endsWith('[]') || result[arrayKey] !== undefined) {
const existing = result[arrayKey];
if (Array.isArray(existing)) {
existing.push(value);
} else if (existing !== undefined) {
result[arrayKey] = [existing, value];
} else {
result[arrayKey] = [value];
}
} else {
result[key] = value;
}
});

return result;
}

// 测试
const url2 = 'https://example.com?tags[]=js&tags[]=ts&id=1&id=2';
console.log(parseQueryWithArray(url2));
// { tags: ['js', 'ts'], id: ['1', '2'] }

支持嵌套对象

type NestedObject = {
[key: string]: string | string[] | NestedObject;
};

function parseQueryNested(url: string): NestedObject {
const result: NestedObject = {};

const queryString = url.split('?')[1]?.split('#')[0];
if (!queryString) return result;

queryString.split('&').forEach((pair) => {
const [rawKey, rawValue = ''] = pair.split('=');
if (!rawKey) return;

const key = decodeURIComponent(rawKey);
const value = decodeURIComponent(rawValue);

// 解析嵌套路径: user[name] -> ['user', 'name']
const path = key
.replace(/\]/g, '')
.split('[')
.filter(Boolean);

setNestedValue(result, path, value);
});

return result;
}

function setNestedValue(
obj: NestedObject,
path: string[],
value: string
): void {
let current = obj;

for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
if (!(key in current)) {
// 下一个 key 是数字则创建数组,否则创建对象
const nextKey = path[i + 1];
current[key] = /^\d+$/.test(nextKey) ? [] : {};
}
current = current[key] as NestedObject;
}

const lastKey = path[path.length - 1];

// 处理数组
if (/^\d+$/.test(lastKey)) {
const index = parseInt(lastKey, 10);
(current as unknown as string[])[index] = value;
} else {
current[lastKey] = value;
}
}

// 测试
const url3 = 'https://example.com?user[name]=John&user[age]=25&colors[0]=red&colors[1]=blue';
console.log(parseQueryNested(url3));
// { user: { name: 'John', age: '25' }, colors: ['red', 'blue'] }

使用原生 API

URLSearchParams

function parseWithURLSearchParams(url: string): Record<string, string> {
const urlObj = new URL(url);
const result: Record<string, string> = {};

urlObj.searchParams.forEach((value, key) => {
result[key] = value;
});

return result;
}

// 或简写
function parseSimple(url: string): Record<string, string> {
return Object.fromEntries(new URL(url).searchParams);
}

// 处理数组
function parseSearchParamsArray(url: string): Record<string, string | string[]> {
const urlObj = new URL(url);
const result: Record<string, string | string[]> = {};

urlObj.searchParams.forEach((value, key) => {
const existing = result[key];
if (existing !== undefined) {
if (Array.isArray(existing)) {
existing.push(value);
} else {
result[key] = [existing, value];
}
} else {
result[key] = value;
}
});

return result;
}

完整 URL 解析

interface ParsedURL {
protocol: string;
host: string;
hostname: string;
port: string;
pathname: string;
search: string;
hash: string;
query: Record<string, string>;
origin: string;
}

function parseURL(url: string): ParsedURL {
const urlObj = new URL(url);

return {
protocol: urlObj.protocol,
host: urlObj.host,
hostname: urlObj.hostname,
port: urlObj.port,
pathname: urlObj.pathname,
search: urlObj.search,
hash: urlObj.hash,
query: Object.fromEntries(urlObj.searchParams),
origin: urlObj.origin,
};
}

// 手动实现(不使用 URL API)
function parseURLManual(url: string): ParsedURL {
const regex = /^(https?:)\/\/([^:/]+)(?::(\d+))?(\/[^?#]*)?(\?[^#]*)?(#.*)?$/;
const match = url.match(regex);

if (!match) {
throw new Error('Invalid URL');
}

const [, protocol, hostname, port = '', pathname = '/', search = '', hash = ''] = match;

const query: Record<string, string> = {};
if (search) {
search.slice(1).split('&').forEach((pair) => {
const [key, value = ''] = pair.split('=');
if (key) {
query[decodeURIComponent(key)] = decodeURIComponent(value);
}
});
}

return {
protocol,
host: port ? `${hostname}:${port}` : hostname,
hostname,
port,
pathname,
search,
hash,
query,
origin: `${protocol}//${hostname}${port ? ':' + port : ''}`,
};
}

// 测试
console.log(parseURL('https://example.com:8080/path?name=John#section'));

序列化参数(反向操作)

function stringifyQuery(params: Record<string, unknown>): string {
const pairs: string[] = [];

const encode = (key: string, value: unknown, prefix = ''): void => {
const fullKey = prefix ? `${prefix}[${key}]` : key;

if (value === null || value === undefined) {
return;
}

if (Array.isArray(value)) {
value.forEach((item, index) => {
encode(String(index), item, fullKey);
});
} else if (typeof value === 'object') {
Object.entries(value).forEach(([k, v]) => {
encode(k, v, fullKey);
});
} else {
pairs.push(
`${encodeURIComponent(fullKey)}=${encodeURIComponent(String(value))}`
);
}
};

Object.entries(params).forEach(([key, value]) => {
encode(key, value);
});

return pairs.join('&');
}

// 测试
console.log(stringifyQuery({ name: 'John', age: 25, tags: ['js', 'ts'] }));
// 'name=John&age=25&tags[0]=js&tags[1]=ts'

console.log(stringifyQuery({ user: { name: 'John', age: 25 } }));
// 'user[name]=John&user[age]=25'

简化版(适合面试现场)

// 最简洁版本
function parseQS(url: string): Record<string, string> {
return url
.split('?')[1]
?.split('#')[0]
?.split('&')
.reduce((acc, pair) => {
const [key, value = ''] = pair.split('=');
if (key) acc[decodeURIComponent(key)] = decodeURIComponent(value);
return acc;
}, {} as Record<string, string>) ?? {};
}

常见面试问题

Q1: 为什么要用 decodeURIComponent?

答案

URL 中的特殊字符和非 ASCII 字符需要编码:

字符编码后
空格%20+
中文%E4%B8%AD
&%26
=%3D
// 编码
encodeURIComponent('北京'); // '%E5%8C%97%E4%BA%AC'

// 解码
decodeURIComponent('%E5%8C%97%E4%BA%AC'); // '北京'

Q2: URLSearchParams 和手写的区别?

答案

特性URLSearchParams手写
兼容性IE 不支持全兼容
功能自动处理编码需手动处理
数组需要遍历可自定义
嵌套不支持可实现

Q3: + 号如何处理?

答案

URL 中 + 可能表示空格(form 编码)或真正的加号:

function parseQueryStringPlus(url: string): Record<string, string> {
const result: Record<string, string> = {};
const queryString = url.split('?')[1]?.split('#')[0];
if (!queryString) return result;

queryString.split('&').forEach((pair) => {
const [key, value = ''] = pair.split('=');
if (key) {
// + 替换为空格再解码
result[decodeURIComponent(key.replace(/\+/g, ' '))] =
decodeURIComponent(value.replace(/\+/g, ' '));
}
});

return result;
}

console.log(parseQueryStringPlus('?name=John+Doe'));
// { name: 'John Doe' }

相关链接