h5开发问题总结
背景
接手了个智慧停车项目,是个h5单页应用,嵌套在“i深圳”App里面,主要功能是给人用来预约停车的。
主要技术组成
构建工具:vite 4.3.1
框架:vue 3.2
组件库:vant UI
地图服务:高德地图
设计稿尺寸还原:px编写尺寸,通过插件转换成vw。
一些问题和解决方法
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的相关逻辑。
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进行编译。
构建骨架屏流程完整代码:
./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>
总结
以上是一些开发问题总结,如有其他想法欢迎留言交流。