美文网首页
h5开发问题总结

h5开发问题总结

作者: 0月 | 来源:发表于2023-12-27 18:33 被阅读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秒

    这里分开两部分讲:

    • 第一部分-传统方法
      直接打开nerwork面板查看,再去lighthouse面板生成一份报告,基本可以确定哪个资源加载耗时比较长了。要想白屏时间短,核心思路就是”少“和”快“
      1、加载更少的资源
      2、加载资源的速度更快
      具体优化手段:
      1、减少不必要的资源加载,通过查看network发现加载了如echarts\echarts-gl等没用到的库,这些库又是由一个vite插件默认自动注入的,通过查看该插件源码发现可以配置一个exclude数组把不需要的库排除掉。
    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秒内首屏可以看到内容。

    • 第二部分-业务相关
      由于业务原因,在真正进入页面之前,还需要用户授权、校验用户登录态、获取i深圳js sdk需要的initCode、获取定位信息等一系列操作,再挂载路由进入路由渲染流程。前面这些操作耗时实测起码也在几百毫秒到1秒之间,这也间接导致了白屏时长的增加。所以在无法避开这些业务耗时的时候,只能采用另外的办法,通用做法如加个loading或者提前在html里面注入骨架屏的方式让用户体验更好一点。由于该项目本来就有页面级别的骨架屏的代码,为了统一视觉效果,也为了复用现成的代码,我决定采用在html里面注入骨架屏的方式。

    项目中原本就存在的骨架屏逻辑:
    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

    总结

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

    相关文章

      网友评论

          本文标题:h5开发问题总结

          本文链接:https://www.haomeiwen.com/subject/splsidtx.html