背景

目前项目已经存在PC、H5两个独立项目,但是应领导需求,我们相关竞品官网都实现了移动端适配方案,因此,我们也需要实现。

接到这个需求我思考的问题是:

  • 如何减少开发工作量,提升后续的可维护性。

方案

  1. 在router中配置同路由下component配置统一的component(动态组件)​实现的Entry组件

  2. ​meta.componentPaths​中配置PC/H5两个组件地址

  3. PC项目全局监听resize事件,用以判断780px(UI给定的)阈值,分别渲染不同组件

  4. ​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
});

落地实践

生成路由

  1. 判断传入的配置是否存在 component​,存在则使用;

  2. 不存在则判断是否存在children(子路由)​,并加入 routeLevel​

  3. 根据 routeLevel​ 像routerConfig中加入 component​配置

    1. ​parent​则使用 ParentEntry​

    2. ​current​则使用 ChildEntry​

    3. 原因:

      1. 当children​下存在某个path与父级路由重复时,子级路由信息会覆盖

      2. 但是matched​会包含所有的匹配路由

      3. 因此要根据 routeLevel​分别取用routerConfig​和matched​中的meta.componentPaths​

  4. 根据 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>

功能:

  1. 监听resize​更新 isMobile​和setIsPC​

  2. 根据 routeLevel​分别取用routerConfig​和matched​中的meta.componentPaths​

  3. 根据meta.componentPaths​配置的组件形式,加入相应缓存及异步处理机制

    1. 兼容 () => import('@/src/**.vue')​ 和(resolve) => require('@/src/**.vue', resolve)​两种动态导入方式

    2. Vue 在遇到(resolve) => require('@/src/**.vue', resolve)​这种函数形式的组件定义时会:

      • 暂停渲染 该组件

      • 调用这个函数,传入 resolve 回调

      • 等待 resolve 被调用

    3. 使用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,
  };
}