pc移动端适配方案
背景
目前项目已经存在PC、H5两个独立项目,但是应领导需求,我们相关竞品官网都实现了移动端适配方案,因此,我们也需要实现。
接到这个需求我思考的问题是:
如何减少开发工作量,提升后续的可维护性。
方案
在router中配置同路由下component配置统一的component(动态组件)实现的Entry组件
meta.componentPaths中配置PC/H5两个组件地址
PC项目全局监听resize事件,用以判断780px(UI给定的)阈值,分别渲染不同组件
src/views下新建pc\h5两个目录,分别放置两个端口的组件,最大程度上支持组件代码的复用性
用例
import Vue from "vue";
import VueRouter from "vue-router";
import { createResponsiveRoute } from "@/utils/pc2h5.js"
Vue.use(VueRouter);
const routes = [
createResponsiveRoute({
path: "/",
// component: resolve =>
// require(['../views/pc/loadFile/Home.vue'],resolve),
children: home,
hidden: true,
name: 'home',
meta: {
componentPaths: {
pc: (resolve) => require(['@/views/pc/loadFile/Home.vue'], resolve),
h5: (resolve) => require(['@/views/h5/home/index.vue'], resolve) // 暂时使用PC版本,后续可替换为H5版本
}
},
})
]
export const router = new VueRouter({
mode: "history",
base: process.env.BASE_URL,
routes
});
落地实践
生成路由
判断传入的配置是否存在 component,存在则使用;
不存在则判断是否存在children(子路由),并加入 routeLevel
根据 routeLevel 像routerConfig中加入 component配置
parent则使用 ParentEntry
current则使用 ChildEntry
原因:
当children下存在某个path与父级路由重复时,子级路由信息会覆盖
但是matched会包含所有的匹配路由
因此要根据 routeLevel分别取用routerConfig和matched中的meta.componentPaths
根据 async配置,判断是否开启路由懒加载
import Entry from "@/components/common/entry/common.vue";
import ParentEntry from "@/components/common/entry/ParentEntry.vue";
import ChildEntry from "@/components/common/entry/ChildEntry.vue";
// 导出Entry组件作为ResponsiveComponent(向后兼容)
export const ResponsiveComponent = Entry;
// 创建响应式路由的工具函数
export function createResponsiveRoute(config) {
const { meta, children, async, ...otherConfig } = config;
const routeLevel = children?.length ? "parent" : "current";
const retConfig = {
...otherConfig,
meta: {
...meta,
responsive: true, // 标记为响应式路由
routeLevel, // 标记为父路由
componentPaths: meta?.componentPaths || config.componentPaths, // 正确获取componentPaths配置
},
children,
};
if (config.component) {
retConfig.component = config.component;
} else {
if (routeLevel === "parent") {
retConfig.component = config.async
? (resolve) =>
require(["@/components/common/entry/ParentEntry.vue"], resolve)
: ParentEntry;
} else {
retConfig.component = config.async
? (resolve) =>
require(["@/components/common/entry/ChildEntry.vue"], resolve)
: ChildEntry;
}
}
return retConfig;
}
// 创建嵌套响应式路由的便捷函数
export function createNestedResponsiveRoute(parentConfig, childrenConfigs) {
// 创建父路由
const parentRoute = createResponsiveRoute(parentConfig);
// 创建子路由数组
const children = childrenConfigs.map((childConfig) => {
return createResponsiveRoute(childConfig);
});
// 组合返回
return {
...parentRoute,
children,
};
}
PC/H5组件渲染实现
结合不同的routeLevel渲染useResponsiveComponent返回的不同组件
<!-- Entry.vue -->
<template>
<div :class="routeLevel === 'parent' ? 'parent-entry-wrapper' : 'child-entry-wrapper'">
<component
:is="currentComponent"
v-bind="$attrs"
v-on="$listeners"
:class="{ 'mobile-layout': isMobile, 'pc-layout': !isMobile }"
/>
</div>
</template>
<script>
import {
defineComponent,
} from '@vue/composition-api';
import { useResponsiveComponent } from './useResponsiveComponent';
export default defineComponent({
name: 'Entry',
props: {
routeLevel: {
type: String,
default: 'current'
}
},
setup(props) {
const {
isMobile,
currentComponent
} = useResponsiveComponent(props.routeLevel); // 固定使用当前路由层级
return {
isMobile,
currentComponent
};
}
});
</script>
<style scoped lang="scss">
.parent-entry-wrapper {
width: 100%;
height: 100%;
}
.child-entry-wrapper {
width: 100%;
height: 100%;
}
</style>
<!-- 包含子路由的组件 -->
<template>
<Entry routeLevel="parent"/>
</template>
<script>
import Entry from './common.vue'
export default {
name: 'ParentEntry',
components: {
Entry
}
}
</script>
<!-- 不包含子路由的组件 -->
<template>
<Entry routeLevel="current"/>
</template>
<script>
import Entry from './common.vue'
export default {
name: 'ChildEntry',
components: {
Entry
}
}
</script>
功能:
监听resize更新 isMobile和setIsPC
根据 routeLevel分别取用routerConfig和matched中的meta.componentPaths
根据meta.componentPaths配置的组件形式,加入相应缓存及异步处理机制
兼容 () => import('@/src/**.vue') 和(resolve) => require('@/src/**.vue', resolve)两种动态导入方式
Vue 在遇到(resolve) => require('@/src/**.vue', resolve)这种函数形式的组件定义时会:
暂停渲染 该组件
调用这个函数,传入 resolve 回调
等待 resolve 被调用
使用require进行异步加载时,需要处理resolve
import {
ref,
computed,
onMounted,
onBeforeUnmount,
getCurrentInstance,
} from "@vue/composition-api";
import { debounce } from "lodash";
// 响应式组件逻辑的 Composition API 实现
export function useResponsiveComponent(routeLevel = 'auto') {
const instance = getCurrentInstance();
const currentScreenWidth = ref(window.innerWidth);
const componentCache = ref(new Map());
// 计算属性
const isMobile = computed(() => currentScreenWidth.value <= 780);
const currentComponent = computed(() => {
const routeInfo = instance?.proxy?.$route;
if (!routeInfo?.name) {
return null;
}
const platform = isMobile.value ? "h5" : "pc";
// 获取当前要处理的路由层级
const targetRoute = getTargetRoute(routeInfo, routeLevel);
if (!targetRoute) {
console.warn(`[useResponsiveComponent] 未找到对应层级的路由配置`, { routeLevel, currentRoute: routeInfo.name });
return null;
}
// console.log(`[useResponsiveComponent] 处理路由: ${targetRoute.name}, 层级: ${routeLevel}, 平台: ${platform}`);
// 获取组件路径
const paths = targetRoute.meta?.componentPaths;
if (paths && paths[platform]) {
let result = paths[platform]
if (typeof result === "function") {
const cacheKey = `${targetRoute.name}-${platform}`;
if (componentCache.value.has(cacheKey)) {
return componentCache.value.get(cacheKey);
}
// 将webpack require包装成Vue异步组件工厂函数
// 如果每次都创建新的函数实例,Vue 会认为这是不同的组件
// 这会导致组件实例被重新创建,而不是复用现有实例
const asyncComponentFactory = () => {
// 兼容两种写法:require 和 import()
// 1. import() 语法:() => import('@/views/pc/index/index.vue') - 无参数
if (result.length === 0) {
// import() 语法:函数无参数,直接调用返回 Promise
return result()
}
// 2. require 语法:(resolve) => require(['@/views/pc/index/index.vue'], resolve) - 有参数
return new Promise((resolve, reject) => {
// require 语法:函数有参数,需要传入回调函数
result((component) => {
// 处理ES模块的default导出
resolve(component.default || component);
});
});
};
componentCache.value.set(cacheKey, asyncComponentFactory);
return asyncComponentFactory;
} else {
return result;
}
} else if (targetRoute.component) {
return targetRoute.component;
} else {
console.warn(`[useResponsiveComponent] 路由 ${targetRoute.name} 没有找到对应的组件配置`);
return null;
}
});
// 根据路由层级获取目标路由
function getTargetRoute(routeInfo, level) {
const matched = routeInfo.matched || [];
if (level === 'auto') {
// 自动模式:优先查找当前路由,然后是最深层的有 componentPaths 的路由
// 1. 先尝试当前路由
if (routeInfo.meta?.componentPaths) {
return routeInfo;
}
// 2. 从最深层开始查找有 componentPaths 的路由
for (let i = matched.length - 1; i >= 0; i--) {
const route = matched[i];
if (route.meta?.componentPaths) {
return route;
}
}
// 3. 如果都没有,返回当前路由
return routeInfo;
} else if (level === 'parent') {
// 父路由模式:查找第一个有 componentPaths 的父路由
for (let i = 0; i < matched.length; i++) {
const route = matched[i];
if (route.meta?.componentPaths && route.children && route.children.length > 0) {
return route;
}
}
return matched[0]; // 如果没找到,返回根路由
} else if (level === 'current') {
// 当前路由模式:只返回当前路由
return routeInfo;
} else if (typeof level === 'number') {
// 数字模式:返回指定层级的路由
return matched[level] || routeInfo;
} else {
// 其他情况返回当前路由
return routeInfo;
}
}
const handleResize = debounce(() => {
const newWidth = window.innerWidth;
instance.proxy.$store.commit('setIsPC', newWidth > 780);
currentScreenWidth.value = newWidth;
}, 100);
// 生命周期
onMounted(() => {
window.addEventListener("resize", handleResize);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleResize);
});
return {
currentScreenWidth,
componentCache,
isMobile,
currentComponent,
handleResize,
};
}