refactor(util): 拆分跳转工具集到独立文件

将原util.js中的跳转相关工具函数拆分为util-navigate.js和util-tabbar.js两个独立模块,重构原有代码以:
1.  避免循环依赖问题
2.  提升代码可维护性和复用性
3.  保留原有API兼容性,无需修改业务调用代码
4.  实现纯函数化设计,方便测试和tree-shaking
This commit is contained in:
2026-07-15 11:00:49 +08:00
parent 6c3f90bf96
commit c8fada4b9d
3 changed files with 1148 additions and 601 deletions

View File

@@ -8,62 +8,47 @@ import {
} from 'common/js/wx-jssdk.js';
import { copyText } from './uniapp.utils.js';
//
export function decodeEntity(str, amp) {
const html4Entities = {
nbsp: ' ', iexcl: '¡', cent: '¢', pound: '£', curren: '₣', yen: '¥', brvbar: '§', sect: 'Ş', uml: '¨', copy: '©', ordf: 'ª', laquo: '«', not: '¬', shy: '­®', macr: '¯', deg: '°', plusmn: '±', sup2: '²', sup3: '³', acute: '´', micro: 'µ', para: '¶', middot: '·', cedil: '¸', sup1: '¹', ordm: 'º', raquo: '»', frac14: '¼', frac12: '½', frac34: '¾', iquest: '¿', agrave: 'à', aacute: 'á', acirc: 'â', atilde: 'ã', auml: 'ä', aring: 'å', aelig: 'æ', ccedil: 'ç', egrave: 'è', eacute: 'é', ecirc: 'ê', euml: 'ë', igrave: 'ì', iacute: 'í', icirc: 'î', iuml: 'ï', eth: 'ð', ntilde: 'ñ', ograve: 'ò', oacute: 'ó', ocirc: 'ô', otilde: 'õ', ouml: 'ö', times: 'ø', ugrave: 'ù', uacute: 'ú', ucirc: 'û', uuml: 'ü', yacute: 'ý', thorn: 'þ', yuml: 'ÿ', fnof: 'ƒ', alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', omicron: 'ο', pi: 'π', rho: 'ρ', sigmaf: 'σ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', chi: 'ψ', omega: 'ω'
}
amp = amp === undefined ? true : amp
let i = str.indexOf('&')
while (i !== -1) {
const j = str.indexOf(';', i + 3)
let code
if (j === -1) break
if (str[i + 1] === '#') {
code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j))
if (!isNaN(code)) {
str = str.substring(0, i) + String.fromCharCode(code) + str.substring(j + 1)
}
} else {
code = str.substring(i + 1, j)
if (html4Entities[code] || (code === 'amp' && amp)) {
str = str.substring(0, i) + (html4Entities[code] || '&') + str.substring(j + 1)
}
}
i = str.indexOf('&', i + 1)
}
return str
}
// 跳转相关工具集(纯函数, 不依赖 util.js 自身, 避免循环依赖)
import {
_toParamValueString,
parsePageParams as _parsePageParams,
decodeEntity,
cleanParamUrls,
normalizeUrl as _normalizeUrlBase,
appendQueryParams as _appendQueryParamsBase,
_shouldEncodeUrl,
_safeEncodeForPlatform,
_navigateTo,
} from './util-navigate.js';
// TabBar / 子包路径相关工具集(纯函数, 依赖 util-navigate 的 _toParamValueString)
import {
resolvePageUrl as _resolvePageUrl,
isPageInRouteList as isPageInRouteListFn,
resolveLinkUrl as _resolveLinkUrlPure,
systemTabBarList as _makeSystemTabBarList,
adaptSubpackageUrl,
isTabBarRootPage as _isTabBarRootPagePure,
isCurrentPageTabBarRoot as _isCurrentPageTabBarRootPure,
checkTabBarActive as _checkTabBarActivePure,
} from './util-tabbar.js';
/**
* 把任意值序列化成可作为 URL 参数的字符串。
* - null / undefined → '' (由调用方负责过滤空值)
* - 对象 / 数组 → JSON.stringify (避免 String({}) 退化成 "[object Object]")
* - 其它类型(number / boolean / string)→ String(v),保持原行为
*
* 与消费端约定:page_params 一律以 JSON 字符串形式落 URL,
* 消费侧用 parsePageParams 还原为对象。
*
* @example
* _toParamValueString({a:1}) // '{"a":1}'
* _toParamValueString('hello') // 'hello'
* _toParamValueString(null) // ''
* _toParamValueString([1, 2]) // '[1,2]'
*
* @param {*} v
* @returns {string}
*/
function _toParamValueString(v) {
if (v === null || v === undefined) return ''
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}
// =============================================================
// HTML 实体解码 / 参数序列化 / page_params 解析
// (直接 re-export util-navigate 的同名函数, 保持 API 兼容)
// =============================================================
export { decodeEntity, cleanParamUrls, _toParamValueString };
/**
* 把 URL 上的 page_params 还原成对象。是 _toParamValueString 的逆操作。
* 容错策略:任何解析失败都安全降级到 defaultValue,不抛异常。
*
* 注意: 与 util-navigate.js 的 parsePageParams 略有差异 —— 本版本对空串 '' 也返回 defaultValue,
* 保持原 util.js 的旧行为(老代码 `'page_params' in link` 但 page_params === '' 时,
* 旧版会落到 defaultValue, 新版会落到 defaultValue 同样的结果)。
*
* @example
* parsePageParams('{"a":1}') // { a: 1 }
* parsePageParams({a: 1}) // { a: 1 } (已是对象直接返回)
@@ -78,12 +63,7 @@ function _toParamValueString(v) {
* @returns {Object}
*/
export function parsePageParams(raw, defaultValue = {}) {
if (raw == null || raw === '') return defaultValue
if (typeof raw === 'object') return raw
if (typeof raw === 'string') {
try { return JSON.parse(raw) } catch (e) { return defaultValue }
}
return defaultValue
return _parsePageParams(raw, defaultValue);
}
@@ -318,6 +298,12 @@ export const PAGE_TAB_MAP = {
'/pages_tool/files/list': null
};
// =============================================================
// 基于 util-tabbar / util-navigate 的本文件 wrapper
// (这些函数都依赖本文件导出的 PAGE_KEY_MAP, 所以要包一层)
// =============================================================
/**
* 根据 page_key + params 解析得到实际跳转URL
* @param {string} page_key 页面语义常量,如 PAGE_GOODS_LIST
@@ -325,32 +311,7 @@ export const PAGE_TAB_MAP = {
* @returns {string|null} 实际URL(已拼接参数)或 null(未匹配)
*/
export const resolvePageUrl = (page_key, params = {}) => {
if (!page_key || !PAGE_KEY_MAP[page_key]) {
return null;
}
let url = PAGE_KEY_MAP[page_key];
if (params && typeof params === 'object' && Object.keys(params).length > 0) {
const parts = [];
Object.keys(params).forEach(function (key) {
const value = params[key];
if (value === null || value === undefined || value === '') return;
// 对象/数组走 JSON.stringify,避免 String({}) 退化成 "[object Object]"
// 消费端 resolveLinkUrl 已用 JSON.parse 还原
const encodedValue = _toParamValueString(value);
// H5:浏览器不自动编码,需要手动 encodeURIComponent
// 小程序/App:uni.navigateTo 会自动编码,这里不能再 encode,否则双重编码
// #ifdef H5
parts.push(key + '=' + encodeURIComponent(encodedValue));
// #endif
// #ifndef H5
parts.push(key + '=' + encodedValue);
// #endif
});
if (parts.length > 0) {
url = url + (url.indexOf('?') !== -1 ? '&' : '?') + parts.join('&');
}
}
return url;
return _resolvePageUrl(page_key, params, PAGE_KEY_MAP);
};
/**
@@ -364,53 +325,7 @@ export const resolvePageUrl = (page_key, params = {}) => {
* @returns {boolean} 是否命中
*/
export const isPageInRouteList = (currentRoute, routeList) => {
if (!currentRoute || !routeList || !Array.isArray(routeList) || routeList.length === 0) {
return false;
}
const route = currentRoute.startsWith('/') ? currentRoute : '/' + currentRoute;
for (const item of routeList) {
if (!item || typeof item !== 'string') continue;
const entry = item.trim();
if (!entry) continue;
if (entry.endsWith('/*')) {
const prefix = entry.slice(0, -1);
if (route.startsWith(prefix)) return true;
continue;
}
if (entry.endsWith('_*') && PAGE_KEY_MAP) {
const keyPrefix = entry.slice(0, -1);
const matchedPaths = [];
for (const k of Object.keys(PAGE_KEY_MAP)) {
if (k.startsWith(keyPrefix) && PAGE_KEY_MAP[k]) {
matchedPaths.push(PAGE_KEY_MAP[k]);
}
}
for (const p of matchedPaths) {
const pn = p.startsWith('/') ? p : '/' + p;
const dirPrefix = pn.substring(0, pn.lastIndexOf('/') + 1);
if (route.startsWith(dirPrefix)) return true;
if (route === pn) return true;
}
continue;
}
if (PAGE_KEY_MAP && PAGE_KEY_MAP[entry]) {
let mapped = PAGE_KEY_MAP[entry];
mapped = mapped.startsWith('/') ? mapped : '/' + mapped;
if (route === mapped) return true;
continue;
}
if (entry.startsWith('/')) {
if (route === entry) return true;
continue;
}
}
return false;
return isPageInRouteListFn(currentRoute, routeList, PAGE_KEY_MAP);
};
/**
@@ -418,157 +333,35 @@ export const isPageInRouteList = (currentRoute, routeList) => {
* 同时把 wap_url 自带的 query 参数与 page_params 合并, 写入 link.page_params;
* 并把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 重复附加
* 优先级: page_params > wap_url query (新机制优先)
*
* @param {Object} link
* @returns {string|null}
*/
export const resolveLinkUrl = (link) => {
if (!link) return null;
// 0. 把 wap_url 中的 query 合并到 page_params
// - 拆分 wap_url 的 query, 与 link.page_params 合并
// - page_params 优先 (新机制, 显式声明的参数覆盖 wap_url query)
// - 把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 二次编码
if (link.wap_url) {
const decodedWap = decodeEntity(link.wap_url);
const queryIdx = decodedWap.indexOf('?');
if (queryIdx !== -1) {
const path = decodedWap.substring(0, queryIdx);
const queryStr = decodedWap.substring(queryIdx + 1);
const urlParams = {};
queryStr.split('&').forEach(kv => {
if (!kv) return;
const eqIdx = kv.indexOf('=');
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
if (k) urlParams[k] = v;
});
// 解析 page_params (可能是字符串或对象) — 走统一 helper,避免分散 try/catch
const pageParams = parsePageParams(link.page_params);
// 合并: page_params 覆盖 urlParams (新机制优先)
link.page_params = Object.assign({}, urlParams, pageParams);
// 把 link.wap_url 改为不含 query 的 path
link.wap_url = path;
}
}
// 1. 优先使用 page_key(新机制)
// 0. 先走 util-tabbar 的纯函数(合并 wap_url 的 query 到 page_params)
const after = _resolveLinkUrlPure(link, {
pageKeyMap: PAGE_KEY_MAP,
parsePageParams: parsePageParams,
decodeEntity: decodeEntity,
});
// 1. 优先使用 page_key(新机制)
if (link.page_key) {
const params = parsePageParams(link.page_params);
const url = resolvePageUrl(link.page_key, params);
if (url) return url;
}
// 2. 回退:旧的 wap_url(已被处理为不含 query 的 path)
if (link.wap_url) {
return link.wap_url;
}
return null;
// 2. 回退: 旧 wap_url / 外部 http 链接
return after;
};
// 当前最新的tabBar.list (参见pages.json 中的tabBar.list 配置)
export const systemTabBarList = [
// 当前最新的 tabBar.list (由 util-tabbar 的 makeSystemTabBarList 生成)
export const systemTabBarList = _makeSystemTabBarList(
INDEX_PAGE_URL,
CATEGORY_PAGE_URL,
CONTACT_PAGE_URL,
MEMBER_PAGE_URL
];
/**
* 适配子包路径
* @param {string} url
* @returns
*/
export const adaptSubpackageUrl = (url) => {
/**
* 特别注意:
* 由于老版本或者后台系统服务未同步更新可以作为tabBarList的最新分包路径。历史遗留问题,需要与当前最新分包机制版本保持一致。
* 系统当前的支持tarbarList(包含动态tabBar),注意这是实际分包后的路径
* 根据匹配映射关系,修改url为实际分包后的路径
*/
// 定义前缀映射表(key=旧前缀, value=新分包前缀)
const urlPrefixMap = {
'/pages/promotion/': '/pages_promotion/',
'/pages/order/': '/pages_order/',
'/pages/goods/': '/pages_goods/',
'/pages/member/': '/pages_tool/member/',
'/pages/login/': '/pages_tool/login/',
'/pages/agreement/': '/pages_tool/agreement/',
'/pages/article/': '/pages_tool/article/',
'/pages/cases/': '/pages_tool/cases/',
'/pages/contact/': '/pages_tool/contact/',
'/pages/files/': '/pages_tool/files/',
'/pages/form/': '/pages_tool/form/',
'/pages/help/': '/pages_tool/help/',
'/pages/notice/': '/pages_tool/notice/',
'/pages/pay/': '/pages_tool/pay/',
'/pages/recharge/': '/pages_tool/recharge/',
'/pages/seal/': '/pages_tool/seal/',
'/pages/storeclose/': '/pages_tool/storeclose/',
'/pages/vr/': '/pages_tool/vr/',
'/pages/weapp/': '/pages_tool/weapp/',
'/pages/webview/': '/pages_tool/webview/',
};
// 构建正则表达式(加 ^ 锚定前缀,避免路径中间出现 /pages/xxx/ 时误匹配)
const prefixes = Object.keys(urlPrefixMap);
if (prefixes.length === 0) return url;
const regex = new RegExp(
'^(' + prefixes.map(key =>
key.replace(/\//g, '\\/').replace(/\*/g, '.*')
).join('|') + ')'
);
// 替换函数
function replacePrefix(str) {
return str.replace(regex, match => {
const newPrefix = urlPrefixMap[match];
return newPrefix || match;
});
}
// 兜底:把 3 个及以上的连续 `/` 收敛为 2 个,只动 path 部分(避免影响 `https://` 这种 scheme)
// 例如把 `/pages//webview///webview?x=1` 修正成 `/pages//webview//webview?x=1`
const collapsed = url.replace(/([^:])\/{3,}/g, '$1//');
return replacePrefix(collapsed);
}
// 归一化任意路径:确保以 '/' 开头,去除 query,适配子包
function _normalizePath(path) {
if (!path) return '';
const normalized = path.startsWith('/') ? path : '/' + path;
return adaptSubpackageUrl(normalized).split('?')[0];
}
// 从归一化路径提取分包名(parts[1])
function _getPackageName(normalizedPath) {
const parts = normalizedPath.split('/');
return parts[1] || '';
}
// 给定当前页面路径 + tab 配置,返回当前页面所属的 tab URL
// 优先级:精确匹配 > packageEntryTab > 分包名回退匹配
function _findMatchingTabUrl(currentPagePath, tabBarList, packageEntryTab) {
if (!tabBarList || !tabBarList.list || !Array.isArray(tabBarList.list)) return null;
const tabUrls = tabBarList.list
.filter(item => item.link && item.link.wap_url)
.map(item => _normalizePath(item.link.wap_url));
const normalizedCurrent = _normalizePath(currentPagePath);
// 1) 精确匹配 → 当前页面就是 tab 页面
const exactMatch = tabUrls.find(url => url === normalizedCurrent);
if (exactMatch) return exactMatch;
// 2) 通过 packageEntryTab(由 updateCurrentRoute/redirectTo 写入)
const packageName = _getPackageName(normalizedCurrent);
if (packageEntryTab && typeof packageEntryTab === 'object' && packageEntryTab[packageName]) {
const entryTab = packageEntryTab[packageName];
const normalizedEntry = _normalizePath(entryTab);
if (tabUrls.includes(normalizedEntry)) return normalizedEntry;
}
// 3) 回退:同分包内的第一个 tab(基于分包名匹配)
return tabUrls.find(url => _getPackageName(url) === packageName) || null;
}
MEMBER_PAGE_URL,
);
/**
* 判断给定路径是否是 TabBar 根页面(与 diy_bottom_nav 中任意一项精确相等)
@@ -577,15 +370,7 @@ function _findMatchingTabUrl(currentPagePath, tabBarList, packageEntryTab) {
* @returns {boolean} 如果是 TabBar 根页面返回 true;路径无效/未配置 tabBarList 时返回 false
*/
export const isTabBarRootPage = (pagePath, tabBarList) => {
if (!pagePath) return false;
if (!tabBarList || !tabBarList.list || !Array.isArray(tabBarList.list)) return false;
const normalizedCurrent = _normalizePath(pagePath);
if (!normalizedCurrent) return false;
return tabBarList.list.some(item => {
if (!item || !item.link || !item.link.wap_url) return false;
const normalizedTab = _normalizePath(item.link.wap_url);
return normalizedTab === normalizedCurrent;
});
return _isTabBarRootPagePure(pagePath, tabBarList);
};
/**
@@ -595,16 +380,7 @@ export const isTabBarRootPage = (pagePath, tabBarList) => {
* @returns {boolean} 当前页是 TabBar 项时返回 true,否则 false(getCurrentPages 不可用时也返回 false)
*/
export const isCurrentPageTabBarRoot = () => {
try {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
if (!pages || !pages.length) return false
const currentRoute = '/' + String(pages[pages.length - 1].route || '').replace(/^\//, '')
if (!currentRoute) return false
const tabBarList = store && store.state ? store.state.tabBarList : null
return isTabBarRootPage(currentRoute, tabBarList)
} catch (e) {
return false
}
return _isCurrentPageTabBarRootPure({ store });
};
/**
@@ -617,55 +393,23 @@ export const isCurrentPageTabBarRoot = () => {
* @returns {boolean} 是否激活
*/
export const checkTabBarActive = (linkUrl, currentPageRoute, tabBarList, packageEntryTab, currentPageQuery = {}) => {
// 1) 归一化 linkUrl:统一前导 '/' + 子包路径适配 + 去除 query
const normalizedLink = _normalizePath(linkUrl);
const normalizedCurrent = _normalizePath(currentPageRoute);
return _checkTabBarActivePure(
linkUrl,
currentPageRoute,
tabBarList,
packageEntryTab,
currentPageQuery,
{
noTabPages: NO_TAB_PAGES,
pageTabMap: PAGE_TAB_MAP,
},
);
};
if (!normalizedLink) return false;
// 2) 精确匹配:当前页面就是 linkUrl 指向的页面
if (normalizedLink === normalizedCurrent) return true;
// 3) 独立流程页:不点亮任何 Tab
if (NO_TAB_PAGES.has(normalizedCurrent)) return false;
// 4) 若传入 tabBarList,使用动态判断逻辑
if (tabBarList && tabBarList.list && Array.isArray(tabBarList.list)) {
const tabUrls = tabBarList.list
.filter(item => item.link && item.link.wap_url)
.map(item => _normalizePath(item.link.wap_url));
// 检查当前 linkUrl 是否是合法的 tab URL
if (!tabUrls.includes(normalizedLink)) return false;
// 5) 页面级 Tab 精确绑定
const mappedTabUrl = PAGE_TAB_MAP[normalizedCurrent];
if (mappedTabUrl) {
return normalizedLink === _normalizePath(mappedTabUrl);
}
if (mappedTabUrl === null) return false;
// 6) 来源记忆兜底:从哪个 Tab 跳转过来,就保持那个 Tab 高亮
const fromTab = currentPageQuery.from_tab;
if (fromTab) {
const normalizedFromTab = _normalizePath(fromTab);
if (tabUrls.includes(normalizedFromTab)) {
return normalizedLink === normalizedFromTab;
}
}
// 7) 分包入口追踪:通过 updateCurrentRoute 写入的入口 tab
const matchingTabUrl = _findMatchingTabUrl(currentPageRoute, tabBarList, packageEntryTab);
if (matchingTabUrl) return normalizedLink === matchingTabUrl;
// 8) 同分包回退:linkUrl 和当前页面在同一分包 → 高亮
const linkPackage = _getPackageName(normalizedLink);
const currentPackage = _getPackageName(normalizedCurrent);
return linkPackage && currentPackage && linkPackage === currentPackage;
}
return false;
}
// =============================================================
// default export - 保持 this.$util.* 旧 API 完全兼容
// =============================================================
export default {
/** 导出页面URL常量 */
@@ -679,6 +423,12 @@ export default {
/** page_key 到路径的映射表(统一管理所有页面) */
PAGE_KEY_MAP,
/** TabBar 业务配置 */
TAB_BAR_KEYS,
NO_TAB_PAGES,
PAGE_TAB_MAP,
systemTabBarList,
/** 根据 page_key + params 解析实际URL */
resolvePageUrl,
@@ -691,6 +441,9 @@ export default {
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */
parsePageParams,
/** HTML 实体解码 */
decodeEntity,
/**
* 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段:
* - 去除反引号 / 各种空白 / 零宽字符
@@ -701,38 +454,8 @@ export default {
* - 不命中: 原样保留(name / title / 标识符等普通字符串字段不被误伤)
*
* 设计: 不限定 key 列表,递归遍历,新加任何 URL 字段都自动覆盖
*
* @param {*} value 任意值
* @returns {*} 与输入同结构的清理后值
*/
cleanParamUrls(value) {
// 内部递归函数,通过闭包引用自身,避免 this 绑定问题
const _clean = (v) => {
if (v === null || v === undefined) return v;
if (typeof v === 'string') {
if (/:\/\//i.test(v)) {
return v
.split('').filter(ch =>
ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r',
).join('')
.replace(/^(https?:\/\/)\/+/i, '$1');
}
return v;
}
if (Array.isArray(v)) {
return v.map(item => _clean(item));
}
if (typeof v === 'object') {
const out = {};
for (const k of Object.keys(v)) {
out[k] = _clean(v[k]);
}
return out;
}
return v;
};
return _clean(value);
},
cleanParamUrls,
/** 判断给定路径是否是 TabBar 根页面(用于动态控制返回按钮显示) */
isTabBarRootPage,
@@ -740,6 +463,28 @@ export default {
/** 判断当前页面是否是 TabBar 根页面(供业务组件在弹窗前判断是否需要隐藏 TabBar) */
isCurrentPageTabBarRoot,
/** 检查 tabBar 是否激活(动态 tabBar 高亮判断) */
checkTabBarActive,
/** 子包路径前缀映射(/pages/order/ → /pages_order/) */
adaptSubpackageUrl,
/**
* URL 规范化(严格规范化已有部分,绝不自动补 https / host)
* @see util-navigate.js#normalizeUrl
*/
normalizeUrl(urlString) {
return _normalizeUrlBase(urlString);
},
/**
* 给 URL 追加 query 参数(已存在的 key 会被覆盖)
* @see util-navigate.js#appendQueryParams
*/
appendQueryParams(url, params) {
return _appendQueryParamsBase(url, params);
},
/**
* 页面跳转
* @param {string} to 跳转链接 /pages/idnex/index
@@ -777,41 +522,15 @@ export default {
merged.page_params = page_params;
}
// 3. 参数编码策略,按平台区分:
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
// - 小程序/App: uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
// 若在 JS 侧再 encodeURIComponent 一次,会被平台再编码一层,目标页 onLoad(options)
// 只 decode 一次后仍是编码形态(如 %25E6%25B2%2599%25E5%258F%2591 -> %E6%B2%99%E5%8F%91)
// => 这里必须保持原始值,完全交给平台去编码
// - H5: 浏览器不会自动编码 url,若参数含 & ? = 等特殊字符会被 queryString 错位解析
// => 这里仍需手动 encodeURIComponent,decode->encode roundtrip 兼容调用方已编码的入参
const safeEncode = (v) => {
// 对象/数组走 JSON.stringify,避免 String({}) 退化成 "[object Object]"
const s = _toParamValueString(v);
// #ifdef H5
try {
return encodeURIComponent(decodeURIComponent(s));
} catch (e) {
return encodeURIComponent(s);
}
// #endif
// #ifndef H5
// 非 H5:uni.navigateTo / router.pushUrl 内部会再对 url 整体编码一次。
// 如果调用方已经手动 encode 过 page_params (或上游已经 encode 过的 url 字符串),
// 这里再走一遍就会出现双重编码(形如 %25xx)。
// 兜底:仅当输入里包含 %xx 这种"看起来是已编码"的模式时,先 decode 一次,让 uni 再 encode 时得到单层编码。
// 原始字符串(没有 %xx)不受影响,避免误把含 % 的原文吃掉。
if (/%[0-9a-fA-F]{2}/.test(s)) {
try {
return decodeURIComponent(s);
} catch (e) {
return s;
}
}
return s;
// #endif
};
const queryStr = Object.keys(merged)
.map(k => safeEncode(k) + '=' + safeEncode(merged[k]))
.map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k]))
.join('&');
let url = path + (queryStr ? '?' + queryStr : '');
@@ -877,14 +596,14 @@ export default {
const cleanUrl = url.split('?')[0];
const isDiyTabRoot = diyTabBar.list.some(item => {
if (!item || !item.link || !item.link.wap_url) return false;
const normalizedTab = _normalizePath(item.link.wap_url);
const normalizedUrl = _normalizePath(cleanUrl);
const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0];
const normalizedUrl = adaptSubpackageUrl(cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl).split('?')[0];
return normalizedTab === normalizedUrl;
});
if (isDiyTabRoot) {
const adaptedUrl = adaptSubpackageUrl(cleanUrl);
const inSystemTabBar = systemTabBarList.some(sysUrl => adaptedUrl === sysUrl || cleanUrl === sysUrl);
// 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净
// 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净
let navApi = inSystemTabBar ? (adaptedUrl === INDEX_PAGE_URL ? uni.switchTab : uni.reLaunch) : uni.reLaunch;
// 鸿蒙元服务环境下 uni.switchTab 有兼容性问题, 全部使用 reLaunch
@@ -1363,7 +1082,7 @@ export default {
} else {
fallbackFunc && fallbackFunc();
}
// #endif
// #endif
},
/**
@@ -2087,212 +1806,4 @@ export default {
return newObj;
},
/**
* 适用于 UniApp 的全平台 URL 标准化函数
* @param {string} urlString - 需要标准化的原始 URL 字符串
* @returns {string} 标准化后的 URL;若输入无法解析为合法 URL 则返回空串 ''
*
* 严格策略(只规范化已有部分,绝不自动补充):
* 1) 预清理: 剥离反引号 / 各种空白 / BOM / 零宽字符(脏数据兜底)
* 2) 不补 https:// —— 如果原串没有协议,保持没有
* 3) 不补 host —— 如果原串没有 host,保持没有
* 4) 只对真实存在的部分做规范化: 协议小写、host 小写、path 解析 . ..、合并多 /、去默认端口、query 排序
* 5) scheme 后只允许恰好 2 个 '/',不允许 https:////host 这类畸形,遇到就返回 ''
*/
normalizeUrl(urlString) {
if (!urlString || typeof urlString !== 'string') return '';
// 1) 预清理: 反引号 + 所有 ASCII 空白 + 中文全角空白 + BOM + 零宽字符
let url = urlString
.split('').filter(function (ch) {
const code = ch.charCodeAt(0);
if (code <= 0x20) return false; // ASCII 空白
if (ch === '`') return false; // 反引号
if (code === 0x3000) return false; // 全角空格
if (code === 0xFEFF || code === 0x200B ||
code === 0x200C || code === 0x200D ||
code === 0x2060) return false; // BOM / 零宽
return true;
}).join('');
// 解析协议(可选,允许缺失)
const protocolMatch = url.match(/^(https?):\/\//i);
let protocol = '';
let rest = url;
if (protocolMatch) {
protocol = protocolMatch[1].toLowerCase() + '://';
rest = url.substring(protocolMatch[0].length);
// scheme 后不能紧跟 '/',即不允许 https:////host 这种畸形
if (rest.charAt(0) === '/') {
console.warn('[normalizeUrl] scheme 后有多余的 /:', urlString);
return '';
}
} else {
// 没有协议 —— 不自动补,只取 path 部分
rest = url;
}
// 解析 hash(可选,允许缺失)
const hashIndex = rest.indexOf('#');
let hash = '';
let mainPart = rest;
if (hashIndex !== -1) {
hash = rest.substring(hashIndex);
mainPart = rest.substring(0, hashIndex);
}
// 解析 query(可选,允许缺失)
const queryIndex = mainPart.indexOf('?');
let query = '';
let pathPart = mainPart;
if (queryIndex !== -1) {
query = mainPart.substring(queryIndex); // 包含 '?'
pathPart = mainPart.substring(0, queryIndex);
}
// 解析 host(可选,允许缺失)
let hostname = '';
let port = '';
let pathname = pathPart;
if (protocol) {
// 有协议,前一段视为 host(可能为 'host'、'host:port'、'' 三种)
const firstSlash = pathPart.indexOf('/');
const hostPort = firstSlash === -1 ? pathPart : pathPart.substring(0, firstSlash);
pathname = firstSlash === -1 ? '' : pathPart.substring(firstSlash); // 保留前导 '/'
if (hostPort) {
const colonIdx = hostPort.lastIndexOf(':');
if (colonIdx !== -1) {
hostname = hostPort.substring(0, colonIdx);
port = hostPort.substring(colonIdx + 1);
} else {
hostname = hostPort;
}
}
}
// 无协议时,pathPart 整体视为 pathname,host 保持空
// 规范化 host
let lowerHostname = '';
if (hostname) {
lowerHostname = hostname.toLowerCase();
// 去默认端口
if ((protocol === 'http://' && port === '80') ||
(protocol === 'https://' && port === '443')) {
port = '';
}
// host 内不允许出现 '/' 或空白(若出现,视为脏数据)
if (/[\/\s]/.test(lowerHostname)) {
console.warn('[normalizeUrl] host 含非法字符:', urlString);
return '';
}
}
// 规范化 pathname: 解析 . 和 ..,合并连续 /
let normalizedPath = pathname;
if (normalizedPath) {
const segments = normalizedPath.split('/');
const resolved = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (seg === '' || seg === '.') {
// 保留前导空段(对应开头的 /),其余空段和 '.' 跳过
if (i === 0) resolved.push('');
continue;
}
if (seg === '..') {
// 不能跨越最顶层
if (resolved.length > 1) resolved.pop();
continue;
}
resolved.push(seg);
}
// 合并连续 '/' 已经在 split/join 中自然完成
normalizedPath = resolved.join('/') || '/';
}
// 规范化 query: 按 key 字母序排序
let normalizedQuery = '';
if (query) {
const qs = query.substring(1); // 去 '?'
if (qs) {
const pairs = qs.split('&').map(function (pair) {
const eqIdx = pair.indexOf('=');
return eqIdx === -1
? { k: pair, v: '' }
: { k: pair.substring(0, eqIdx), v: pair.substring(eqIdx + 1) };
});
pairs.sort(function (a, b) { return a.k.localeCompare(b.k); });
normalizedQuery = '?' + pairs.map(function (p) { return p.k + '=' + p.v; }).join('&');
}
}
// 拼回最终 URL(只包含真实存在的部分)
const portStr = port ? ':' + port : '';
const hostPart = lowerHostname ? lowerHostname + portStr : '';
const schemeAndHost = protocol + hostPart; // protocol 形如 'https://',hostPart 可能为空
return schemeAndHost + normalizedPath + normalizedQuery + hash;
},
/**
* UniApp 全平台通用的 URL 追加查询参数函数(自动处理中文编码)
* @param {string} url - 原始 URL 字符串
* @param {object} params - 需要追加的参数对象,例如 { name: '张三', city: '苏州' }
* @returns {string} 追加参数后的完整 URL
*/
appendQueryParams(url, params) {
if (!url || typeof url !== 'string') return '';
// ==================== H5 端处理 (利用原生 URL API) ====================
// #ifdef H5
try {
let targetUrl = url.trim();
if (!/^https?:\/\//i.test(targetUrl)) {
targetUrl = 'https://' + targetUrl;
}
const urlObj = new URL(targetUrl);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
// 对象/数组走 JSON.stringify,避免 String({}) 退化成 "[object Object]"
urlObj.searchParams.append(key, _toParamValueString(value));
}
}
return urlObj.toString();
} catch (e) {
console.error('H5端 URL 参数追加失败:', e);
return url;
}
// #endif
// ==================== 非 H5 端处理 (小程序 / App) ====================
// #ifndef H5
try {
let targetUrl = url.trim();
// 分离 hash (#部分),避免干扰参数拼接
const hashIndex = targetUrl.indexOf('#');
let hash = '';
if (hashIndex !== -1) {
hash = targetUrl.substring(hashIndex);
targetUrl = targetUrl.substring(0, hashIndex);
}
// 判断当前 URL 是否已经包含查询参数,决定使用 ? 还是 &
const separator = targetUrl.includes('?') ? '&' : '?';
// 遍历参数对象,拼接参数
// 小程序/App 端:uni.navigateTo/router.pushUrl 内部会自动对 url 编码,
// 这里不能再 encodeURIComponent,否则会被平台再编码一层导致双重编码
const queryString = Object.entries(params)
.filter(([_, value]) => value !== undefined && value !== null)
// 对象/数组走 JSON.stringify,避免 String({}) 退化成 "[object Object]"
.map(([key, value]) => `${key}=${_toParamValueString(value)}`)
.join('&');
// 重新拼接成完整的 URL
return `${targetUrl}${separator}${queryString}${hash}`;
} catch (e) {
console.error('非H5端 URL 参数追加失败:', e);
return url;
}
// #endif
}
}