h5开发问题总结

2023-12-27  本文已影响0人  0月

背景

接手了个智慧停车项目,是个h5单页应用,嵌套在“i深圳”App里面,主要功能是给人用来预约停车的。

主要技术组成

构建工具:vite 4.3.1
框架:vue 3.2
组件库:vant UI
地图服务:高德地图
设计稿尺寸还原:px编写尺寸,通过插件转换成vw。

设计稿.png

一些问题和解决方法

1、页面不定时崩溃刷新

场景:进入应用后,只要你操作跳转到其他页面,连着切换几个页面之后突然在某个页面自动刷新了
原因:内存溢出,webview自动刷新
解决:通过chrome内存面板分析可以看到,随着不断切换页面看到内存一直变大,由此入手排查代码,发现是地图组件没有销毁导致的问题。

原来的@lib/vue3-amap组件:

<template>
  <div ref="amapRef" />
</template>
<script>
const amapRef = ref()
const map = ref()
// 在onMounted 之后new一个Map实例
map.value = new AMap.Map(amapRef.value, {...});
// 通过provide注入给后代组件
provide('map', map)
</script>

原来使用组件方式

import Amap from '@lib/vue3-amap';
<Amap>
  <LocationLayer ref="locationLayerRef" />
</Amap>

LocationLayer.vue里面 通过inject拿到map实例,再实现业务细节,比如添加图层

const map = inject('map');
const parkingLabelLayer= new AMap.LabelsLayer({...});
map.add(parkingLabelLayer);

而解决问题后的地图组件@lib/vue3-amap,需要加上销毁map实例这一步

<template>
  <div ref="amapRef" />
</template>
<script>
const amapRef = ref()
const map = ref()
// 在onMounted 之后new一个Map实例
map.value = new AMap.Map(amapRef.value, {...});
// 通过provide注入给后代组件
provide('map', map)

onUnmounted(() => {
  map.value.destroy?.() // 销毁map实例,避免内存泄漏
});
</script>

同样原来在使用地图实例的时候也有问题:未移除图层,需要处理:


移除图层

所以,凡是需要传入dom节点作为参数的构造函数,都要有一个意识去看在用完该函数之后是否需要解除dom的引用。同理,在一个对象上添加东西的时候也要有用完之后移除掉的意识

2、在ios系统下,存在安全区域外渲染html元素

ios底部黑色条.png
场景:在写样式的时候需要注意底部有黑色横条的时候加上相关padding,不然容易被这黑条挡住
解决方法:可以在html文件加这行代码,然后body上定义一个--fix-bottom的变量
// html
 <meta name="viewport" content="viewport-fit=cover">
// css
@supports (bottom: env(safe-area-inset-bottom)) {
  body {
    --fix-bottom: constant(safe-area-inset-bottom);
    --fix-bottom: env(safe-area-inset-bottom);
  }
}

在其他用的地方再使用

padding-bottom: var(--fix-bottom)

3、页面中的滑动面板组件在ios系统下难以滑动

场景:在开发详情页面中,发现当有滚动条时,滑动面板就滑动不了,原来的滑动面板组件代码:

  <div 
    ref="container"
    @touchstart="onTouchstart"
    @touchmove="onTouchmove"
    @scroll.capture="onScroll($event)">
      <slot />
  </div>
let startY = -1;
let isMoveFromScroll = false;
let touchStartTime = Date.now();

const onTouchstart = e => {
  startY = e.touches[0].clientY;
  touchStartTime = Date.now();
}
// 加上50ms节流,防止滑动冲突
const onTouchmove = throttle(e => {
  const deltaY = e.touches[0].clientY - startY;

  let touchMoveTime = Date.now();
  if (touchMoveTime - touchStartTime > 500 && touchMoveTime - touchStartTime < 1000) {
    return;
  }

  if (isMoveFromScroll) {
    return;
  }

  // 向下滑动超过30
  if (deltaY > 30) {
    if (state.value === 'half') {
      return;
    }
    state.value = 'half';
  }
  // 向上滑动超过30
  if (deltaY < -30) {
    if (state.value === 'full') {
      return;
    }
    state.value = 'full';
  }
}, 50)


let scrollTimeout: any = -1;
// 如果触发了滚动事件,则一会滑动是不能触发变大变小事件的
const onScroll = e => {
  isMoveFromScroll = true;
  clearTimeout(scrollTimeout);
  scrollTimeout = setTimeout(() => {
    isMoveFromScroll = false;
  }, 200)
}

代码原理:在touchstart的时候记录y轴位置信息a,touchmove的时候拿当前y轴位置信息b , b减去a得出移动距离,然后两者差值满足30以上就切换状态: half —> full,或者full —> half。

其中的onScroll 就是打个标识isMoveFromScroll 让touch事件判断有滚动的时候让滑动失效的。为啥要这么做???原因是scroll事件不仅仅是自身可以触发的,还可以是内部元素触发的,如果去掉scroll处理,那么内部列表在滚动时,面板也在同步滑动了,为了让内部列表元素在滚动时,滑动面板不滑动,所以做了这个处理。
然后现在问题是详情页面内容超高,自身出现滚动条了,此时这个scroll事件也起到了作用,当触发滚动时,不能滑动了。

解决方法:在scroll事件里面判断,滚动事件优先,如果滚动到顶或者到底了,那么此时不再设置拦截isMoveFromScroll 去阻止touch的相关逻辑。

scroll修改.png

4、绘制在高德地图图层的停车场图标在ios15.5系统下显示太小

场景: 由于需要在地图上画出停车场的落点marker icon,UI统一设置icon显示32px大小,结果在ios15.5下显示很小,只有10px左右。
原因:由于我们采用svg的data:image/svg+xml 字符串的方式赋值给img标签,然后img onload之后通过canvas.toDataURL()去转换成png图片类型的字符串,再把png画到地图图层上,这个过程中,svg标签没有设置width height导致在老系统出了问题。


svg字符串.png

5、地图自定义样式加载失败

高德地图样式加载失败.png
通过上面的对比可以看出,左边的是高德地图默认的底图样式,右边是我们根据自定义地图自定义的样式,但是有些手机还是加载不出来自定义样式的底图,可以加这一句:
// 无法显示自定义地图,这个属性可以强制只要支持webgl的浏览器都使用自定义底图
window.forceWebGL = true;

6、keep-alive的使用问题

场景:列表页—>详情页—>列表页,列表页要保持状态不变,包括滚动条,操作交互等。列表页—>非详情页—>列表页,列表页正常更新。
解决方法:一般这种缓存状态的都用keep-alive包裹着,同时注意缓存路由只能是同层级路由切换,不能跨高层级切换,比如二级路由跳到一级路由再回到二级路由,是无法缓存原来二级路由的,但是可以一级路由跳到二级路由再回到一级,一级还能缓存住。

首先,同级路由设置keep-alive,通过route.meta.keepAlive标识哪个路由是要缓存的。

      <router-view v-slot="{ Component, route }">
        <keep-alive>
          <component v-if="route.meta.keepAlive === true" :is="Component" :key="route.name" />
        </keep-alive>
        <component v-if="route.meta.keepAlive !== true" :is="Component" :key="route.name" />
      </router-view>

上面只实现 :列表页—>其他页—>列表页 这个过程中,列表页缓存不变,但是不能实现非详情页回到列表页更新,所以,我们可以在列表页做一些判断,下面有两种方案:
1、在从非详情页进来列表页时,列表页销毁重新渲染。
2、在从列表页去非详情页的时候,销毁列表页,其他页面时回来再重新渲染列表页。

但是第一种其实有问题,去其他页面的时候,其实可以不用缓存的,所以这里采用第二种方法

定义一个缓存页面的hook

import { onActivated, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { ROUTER_NAME } from '@/router';

const BACK_PAGES = [ROUTER_NAME.placeDetail, ROUTER_NAME.parkingDetail]; // 进去的详情页路由

// 要缓存的页面
export function cachePageHandle() {
  // 默认false 然后在onActivated设置true, 可以让子组件刚开始只触发mounted不会触发activated
  const isPageReady = ref(false);
  onActivated(() => {
    isPageReady.value = true; // 无论是组件刚开始渲染,或者从其他页面回来都是要设置true渲染的
  });

  // 如果是进入非详情页面,则销毁组件
  onBeforeRouteLeave(leaveGuard => {
    if (!BACK_PAGES.includes(leaveGuard.name as string)) {
      isPageReady.value = false;
    }
  })

  return {
    isPageReady
  }
}

列表页home/index.vue 使用该hook:

<template>
  <HomeCom v-if="isPageReady" />
</template>

<script lang="ts" setup>
import HomeCom from './home.vue';
import { cachePageHandle } from '@/hooks/use-cache-page';
const { isPageReady } = cachePageHandle();
</script>

如此做法既可以让路由页面被缓存,同时也能在页面组件内部自我控制销毁、创建实现需要的效果。

7、手机4g网络下打开,白屏时间基本4至8秒

这里分开两部分讲:

plugins: [
      vue(),
      vitePluginLibStaticImport({
        exclude: [
          'echarts',
          'echarts-gl',
          'echarts-liquidfill',
        ]
      }),
      ...
]

2、让资源加载更快,我在html > head里设置了非主域名的dns-prefetch: <link rel="dns-prefetch" href="//webapi.amap.com" />;同时让运维对nginx进行一些配置: html|js|css开启gzip, 静态资源(js|css|png|jpe?g|svg)开启强缓存,html资源开启协商缓存,开启http2协议,但是由于相关配置经过公司的安全扫描改动过,不好再改,暂时就只开了gzip 和静态资源的协商缓存。

经过优化,4g网络下打开基本在2秒内首屏可以看到内容。

项目中原本就存在的骨架屏逻辑:
1、在store中定义skeletonName: ''; 在App.vue引入所有页面骨架屏组件,通过v-if=“skeletonName”去控制骨架屏的显示隐藏
2、路由跳转前设置skeletonName = to.name,此时根据App.vue里面的骨架屏会根据skeletonName 显示对应骨架屏
3、在对应页面组件里面,当数据准备好或者mounted之后把skeletonName 置空,此时App.vue里面的骨架屏消失,显示对应页面。

示意代码如下
router.ts

router.beforeEach(async (to, from, next) => {
    // 对应路由名称加载骨架屏
    store.changeSkeletonName(to.name);
})

有骨架屏的页面组件在特定时机去隐藏骨架屏

import { useSkeletonStore } from '@/store';
const skeletonStore = useSkeletonStore();
onMounted(() => {
  if (skeletonStore.skeletonName) skeletonStore.changeSkeletonName('');
})

骨架屏控制组件

<template>
  <component style="position:fixed;width:100%;height:100%;overflow:hidden;background-color:white;z-index:888;" :is="componentId" />
</template>
<script setup lang="ts">
import { computed, h } from 'vue';
import { useSkeletonStore } from '@/store';
import ParkDetail from './park-detail.vue';
import Home from './home.vue';
import Remainder from './remainder.vue';
import { ROUTER_NAME } from '@/router';

const store = useSkeletonStore()
const componentId = computed(() => {
  const map = {
    [ROUTER_NAME.home]: Home,
    [ROUTER_NAME.remainder]: Remainder,
    [ROUTER_NAME.placeDetail]: ParkDetail,
    [ROUTER_NAME.parkingDetail]: ParkDetail,
  }
  const c = map[store.skeletonName]
  return c ? h(c) : null
})
</script>

以上现有的代码,我是如何复用呢?首先梳理一下骨架屏需要的代码是啥?骨架屏一般有图片或者html+css两种形式,这里既然要复用,那么实际上就是要提取出首屏渲染需要的./skeleton/home.vue的 渲染后的html+css 代码注入到html文件里面去了。问题是怎么拿到./skeleton/home.vue渲染后的html+css呢?看看ssr是怎么做的,官方例子

// 此文件运行在 Node.js 服务器上
import { createSSRApp } from 'vue'
// Vue 的服务端渲染 API 位于 `vue/server-renderer` 路径下
import { renderToString } from 'vue/server-renderer'

const app = createSSRApp({
  data: () => ({ count: 1 }),
  template: `<button @click="count++">{{ count }}</button>`
})

renderToString(app).then((html) => {
  console.log(html)
})

通过例子我们可以知道,只要把sfc编译好就能传进去给createSSRApp方法得到html字符串。怎么编译呢?这也容易让人想到vue官方提供的@vue/compiler-sfc,但是仔细想想这有点多工作量,因为./skeleton/home.vue里面又引入了vant-skeleton组件,里面又有一些其他的依赖关系,自己去处理这些依赖关系比较麻烦。所以有没有现成的工具直接编译一个sfc组件的?答案是肯定的,现成的vite就支持通过设置lib方式打包出一个直接能用的vue组件,那么到这里整个流程就通了:

1、单独配置一份打包vue组件的配置,打出编译后的组件,此时会得到有渲染函数的组件+对应的css;
2、通过动态import加载编译后的js文件,得到component,再传入给createSSRApp + renderToString得到html字符串,同时也从打包后的css文件读取css字符串,有了css + html字符串就是完整的骨架屏代码;
3、最后通过vite插件vite-plugin-ejs把骨架屏代码注入到html文件里。

其中这里先打包再加载的思想我参考了vite源码中的获取vite.config.[mc][tj]s配置的逻辑:对于esm写法的config,Vite 会将编译后的.mjs bundledCode写入临时文件,通过原生Node ESM Import 来读取这个临时的内容,再直接删掉临时文件。而对于commonjs写法的config,vite会通过临时重写原生_require.extensions[ext]方法(ext取自vite.config.[ext]),方法内部针对config文件路径进行拦截,当请求该文件时,直接调用Node原始的module._compile方法对打包后的.cjs bundledCode进行编译。

loadConfigFromBundledFile.png

构建骨架屏流程完整代码:

./skeleton/build.ts

import { build, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import vitePluginLibVantImport from 'vite-plugin-lib-vant-import';
import path from 'path';
import { pathToFileURL } from 'node:url';
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import fs from 'node:fs'
import fsp from 'node:fs/promises'

const resolve = p => path.resolve(__dirname, p);

// 生成html字符串
const renderHtmlString = async (component) => {
  const app = createSSRApp(component);
  return renderToString(app);
}

// 生成骨架屏代码
export async function createSkeleton() {
  await build({
    configFile: false, // 调用build api时必设置为false
    publicDir: false,
    plugins: [
      vue(),
      vitePluginLibVantImport()
    ],
    build: {
      rollupOptions: {
        external: ['vue'],
        output: {
          exports: 'named',
          globals: {
            vue: 'Vue',
          },
        },
      },
      sourcemap: false,
      lib: {
        entry: resolve('../src/views/skeleton/home.vue'), // 首屏骨架屏
        name: 'skeleton',
        fileName: 'index',
        formats: ['es'], // 导出模块类型
      },
      outDir: resolve('./dist'),
    },
  });

  const fileUrl = pathToFileURL(resolve('./dist/index.mjs')).href;
  const component = (await import(fileUrl)).default; // 加载组件
  const htmlString = await renderHtmlString(component); // 拿html字符串
  let style = await fsp.readFile(resolve('./dist/style.css'), 'utf-8') // 拿样式字符串
  style = `<style>\n${style}</style>`;
  fs.rm(resolve('./dist',), { recursive: true }, () => { }); // 删除build后的dist目录
  const skeleton = `\n${style}\n${htmlString}\n`; // 骨架屏代码
  return skeleton;
}

vite.config.ts

import vue from '@vitejs/plugin-vue';
import { ViteEjsPlugin } from "vite-plugin-ejs";
import { createSkeleton } from './skeleton/build';
import type { UserConfig, ConfigEnv } from 'vite';

export default async ({ command, mode }: ConfigEnv): Promise<UserConfig> => {
  const isBuild = command === 'build';

  let skeleton = ''
  if (isBuild) {
    skeleton = await createSkeleton() // 打包时再注入骨架屏,开发环境不注入
  }

  return {
    ...
    plugins: [
      vue(),
      ViteEjsPlugin ({
          title: '标题',
          skeleton
      }),
    ],
  }
}

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title><%= title %></title>
  </head>
  <body >
    <div id="app"><%- skeleton %></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

可以看到 在html里面采用ejs语法:<%- skeleton %>,配合vite-plugin-ejs插件注入骨架屏代码。当运行npm run build就会得到打包后的index.html,body标签下不再是孤零零的<div id="app"></div>

有骨架屏的html文件.png 骨架屏预览效果.png

总结

以上是一些开发问题总结,如有其他想法欢迎留言交流。

上一篇下一篇

猜你喜欢

热点阅读