迁移兼容问题

实习过程中面对过一个复杂需求:H5仓库进行新版本迁移,还要兼容小程序。此篇文章特此记录H5和小程序的差异情况,相当于踩坑笔记吧。

一、wxSDK替代

程序会遇到三种情况:H5、小程序、小程序内嵌H5。

为此针对上述情况,需要进行判别函数的撰写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export async function isMiniProgram(): Promise<boolean> {
let IS_H5 = true;
// #ifdef MP-WEIXIN
IS_H5 = false;
// #endif
if (IS_H5) {
// 超过3s,当做异常情况
const [err, res] = await to(promiseTimeout(new Promise((resolve) => {
weixinModule.invoke('miniProgram.getEnv', (env) => {
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
resolve(env && env.miniprogram);
});
}), 3000));

// 判断异常,当为非小程序环境
return !err && res;
}
return false;
}

这里会涉及到wxSDK使用uniapp的能力进行替代。

  • miniProgram.reLaunch
    reLaunch的功能主要有两个:关闭所有页面、打开指定页面。
    使用场景:用户登录/注销、应用重置、错误处理。
    我这里遇到的场景是用户删卡后回首页,类似于用户注销。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const goHome = async () => {
     // 小程序内嵌时,删卡直接回小程序首页(为了防止回退,直接reLaunch)
    const isMini = await isMiniProgram();
    if (isMini) {
      weixinModule.invoke('miniProgram.reLaunch', { url: '/pages/index/index' });
      return;
    }
    if (!IS_H5) {
      return uni.reLaunch({
        url: '/pages/index/index',
      });
    }
    navigateTo({ path: P2_INDEX });
    };
  • miniProgram.getEnv
    用于判断当前的运行环境, 通常在网页被调用,以确定该网页是否在微信小程序的环境中运行。此方法在上述的判断环境中进行使用。

  • miniProgram.navigateTo
    要进行页面跳转进行对应性兼容。

    1
    2
    3
    4
    5
    6
    const isMini = await isMiniProgram();
    if (isMini) {
    ...
    weixinModule.invoke('miniProgram.navigateTo', { url: `${'/pages/card-remove/index'}?${stringify(query)}` });
    return;
    }

二、页面适配

小程序不能使用DOM、BOM用法,同时获取url query的实际时序问题,这些其实需要视情况而定,我选取几个特殊场景进行举例吧。

2.1 页面渲染

render和template都是用于定义视图层的内容和结构的方式。

但小程序不支持使用render函数来定义页面的视图层,为此需要将render函数的方式转变为template。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const svgMap = {
tick: Tick,
security: Security,
};
export default Vue.extend({
name: 'FitIcon',
props: {
icon: { // 弹出框标题
type: String,
default: 'info',
},
fill: {
type: String,
},
size: {
type: [String, Number],
},
},
render(createElement: CreateElement): VNode {
if (svgMap[this.icon]) {
return createElement(svgMap[this.icon], {
props: {
fill: this.fill,
size: this.size,
},
class: {
[`fit-icon-${this.icon}`]: true,
},
});
}
return createElement(Icon, {
props: {
icon: this.icon,
},
class: {
[`fit-icon-${this.icon}`]: true,
},
});
},
});

解决思路:H5 方面通过组件的引入来直接使用,通过组件名字的对应关系来实现。小程序无法直接使用 @Component 这种方式来定义和注册组件的。可以通过父子组件传输组件名,去加载相关组件名和对应样式(这里发现样式名与组件名有关联,直接采用写死的方式减少数据传输)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<template>
<div>
<!-- #ifdef H5 -->
<component v-if="svgMap[icon]"
:is="svgMap[icon]"
:fill="fill"
:size="size"
:class="{['fit-icon-' + icon]: true}">
</component>
<Icon v-else :icon="icon" :class="{['fit-icon-' + icon]: true}"></Icon>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<AllComponent :cId="compName"></AllComponent>
<!-- #endif -->
</div>
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import Icon from './src/index.vue';
import Tick from './src/tick.vue';
...

// #ifdef MP-WEIXIN
import AllComponent from './src/DynamicComponent.vue';
// #endif

@Component({
components: {
Icon,
Tick,
Security,
...

// #ifdef MP-WEIXIN
AllComponent,
// #endif
},
})

export default class FitIcon extends Vue {
@Prop({ type: String, default: 'info' }) icon!: string;
@Prop(String) fill!: string;
@Prop({ type: [String, Number] }) size!: string | number;
@Prop() compName!: string;

svgMap = {
tick: Tick,
security: Security,
...
};
}

小程序子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<template>
<icon v-if="cId === 'icon'" class="fit-icon-icon" @compCreated="handleCreated"></icon>
<tick v-else-if="cId === 'tick'" class="fit-icon-tick" @compCreated="handleCreated"></tick>
<security v-else-if="cId === 'security'" class="fit-icon-security" @compCreated="handleCreated"></security>
</template>

<script lang="ts">
import { Component, Vue, Prop, Emit } from 'vue-property-decorator';

import Icon from './index.vue';
import Tick from './tick.vue';
import Security from './security.vue';

@Component({
components: {
Icon,
Tick,
Security,
...
},
})
export default class BusinessComponent extends Vue {
@Prop() cId!: String;

@Emit('asyncCompCreated')
private handleCreated() {}
}
</script>

2.2 DOM操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import ModICBCOpenBill from '../mod-icbc-openbill/index.vue';

const instance = {};
instance.install = function (Vue) {
const IcbcContrustor = Vue.extend(ModICBCOpenBill);
// 动态创建并挂载一个组件实例
const icbc = new IcbcContrustor();
icbc.$mount(document.createElement('div'));
// 插入到DOM中
document.body.appendChild(icbc.$el);
// 组件实例挂载到Vue原型上
Vue.prototype.$icbc = icbc;
};

export default instance;

这段代码实现了一个Vue插件,动态创建并挂载组件实例,接着将实例的根元素(icbc.$el)插入到DOM中,这样组件就可被动态添加到页面中,最后将组件实例挂载到Vue原型上。

但遇到一个问题:但小程序无document.createElement的功能。

解决思路:直接引入并使用ModICBCOpenBill,通过在组件实例赋值方式实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div>
<ModICBCOpenBill ref="$icbc"></ModICBCOpenBill>
</div>
</template>

<script lang="ts">
import ModICBCOpenBill from './component/mod-icbc-openbill/index.vue';

export default defineComponent({
...
components: {
...,
ModICBCOpenBill,
},
setup(props, ctx: SetupContext) {
return {
...pageSetup(props, ctx),
};
},
</script>

在 setup 中将组件的实例赋值给一个名为 $icbc 的引用,接着在组件挂载到 DOM 之前,将实例赋值给 Vue 的原型属性 icbc。

1
2
3
4
5
6
7
8
9
import { ref, Ref, computed, SetupContext, nextTick, onBeforeMount } from '@vue/composition-api';
import Vue from 'vue';

export default function setup(..., _ctx: SetupContext) {
...
onBeforeMount(() => {
Vue.prototype.icbc = _ctx.refs.icbc;
});
}

2.3 时序问题

在小程序中,getCurrentPages方法用于获取当前页面栈(页面栈是一个数组,包含当前小程序中所有已打开的页面实例),通常情况下,我们可以通过getCurrentPages获取页面栈并进行相应的操作。然而,通过全局函数(如navigateTo、navigateBack)进行页面跳转,页面栈更新不会立即完成(异步)。

为解决上述问题,常见的解决方案有两种:一种是使用async/await;另一种是使用回调函数。我选择的是第一种,在页面跳转后,使用await nextTick()等待页面栈更新完成,再调用getCurrentPages获取最新的页面栈信息。

1
2
3
4
5
6
// 页面初始化
const init = async () => {
await nextTick();
const query = getParaAdaptHash(); //调用 getCurrentPages 方法
...
}

但遇到了新的问题,页面初始化原本再setup中调用,而实际DOM操作在beforeMount生命周期钩子中,对此需要进行调整。

1
2
3
4
onBeforeMount(() => {
...
init();
})

三、组件适配

这里以日期选择器为例,迁移到小程序中,会遇到日期选择器样式失效、选择日期有误(日期天数有误)等各种问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
<template>
  <view
    class="weui-picker__group fit-date-picker"
    id="picker"
    ref="picker"
    @touchstart="onTouchstart"
    @touchmove="onTouchMove"
    @touchend="onTouchEnd"
  >
    <view class="weui-picker__mask"></view>
    <view class="weui-picker__indicator"></view>
    <view class="weui-picker__content" :style="[groupCss]">
      <view
        class="weui-picker__item"
        v-for="(item, index) in items"
        :key="index"
        :class="{ 'weui-picker__item_disabled': item.disabled, 'weui-picker__item_active': curIndex === index }"
      >
        <view v-if="item.htmlLabel" v-html="item.htmlLabel"></view>
        <span v-else>{{ item.label }}</span>
      </view>
    </view>
  </view>
</template>
<script lang="ts">
import { IS_H5 } from '@/fit-mod';
import Vue from 'vue';
interface Point {
  y: number;
  time: number;
}
const getMax = (offset: number, rowHeight: number) => offset * rowHeight;

/**
 * get min translate
 * @param offset
 * @param rowHeight
 * @param length
 * @returns {number}
 */
const getMin = (offset: number, rowHeight: number, length: number) => -(rowHeight * (length - offset - 1));
export default Vue.extend({
  props: ['items', 'temp'],
  data() {
    const points: Point[] = [];
    return {
      translate: 0,
      transitionTime: 0,
      offset: 2, // 列表初始化时的偏移量(列表初始化时,选项是聚焦在中间的,通过offset强制往上挪3项,以达到初始选项是为顶部的那项)
      rowHeight: 48, // 列表每一行的高度
      bodyHeight: 5 * 48,
      start: 0,
      end: 0,
      startTime: +new Date(),
      points,
      curTranslate: 0,
      curTransitionTime: 0,
      curIndex: -1, // 初始化选中项,默认为-1,表示不选中任何项
    };
  },
  // #ifdef MP-WEIXIN
  ...{
    options: {
      virtualHost: true,
    },
  },
  // #endif
  computed: {
    groupCss(): any {
      return {
        '-webkit-transform': `translate3d(0, ${this.curTranslate}px, 0)`,
        transform: `translate3d(0, ${this.curTranslate}px, 0)`,
        '-webkit-transition': `all ${this.curTransitionTime}s`,
        transition: `all ${this.curTransitionTime}s`,
      };
    },

  },
  watch: {
    temp() {
      this.initTranslate();
    },
  },
  created(): void {
    this.initTranslate();
  },
  methods: {
    getDefaultTranslate(offset: number, rowHeight: number, items: number) {
      const currentIndex = this.getDefaultIndex(items);
      return (offset - currentIndex) * rowHeight;
    },
    onChange(item: any, index: number) {
      this.curIndex = index;
      this.$emit('onChange', item, index);
    },
    getDefaultIndex(items: any) {
      let current = Math.floor(items.length / 2);
      let count = 0;
      while (!!items[current] && items[current].disabled) {
        current = ++current % items.length;
        count++;

        if (count > items.length) {
          throw new Error('No selectable item.');
        }
      }

      return current;
    },
    initTranslate(): void {
      if (this.temp !== null && this.temp < this.items.length) {
        const index = this.temp;
        this.onChange.call(this, this.items[index], index);
        this.translate = (this.offset - index) * this.rowHeight;
      } else {
        const index = this.getDefaultIndex(this.items);
        this.onChange.call(this, this.items[index], index);
        this.translate = this.getDefaultTranslate(
          this.offset,
          this.rowHeight,
          this.items,
        );
      }
      this.setTranslate(this.translate);
    },
    $$start(pageY: number) {
      this.start = pageY;
      this.startTime = +new Date();
    },
    setTransition(time: number) {
      this.curTransitionTime = time;
    },
    setTranslate(translate: number) {
      this.curTranslate = translate;
    },
    $$move(pageY: number) {
      this.end = pageY;
      const diff = this.end - this.start;
      this.setTransition(0);
      this.setTranslate(this.translate + diff);
      this.startTime = +new Date();
      this.points.push({ time: this.startTime, y: this.end });
      if (this.points.length > 40) {
        this.points.shift();
      }
    },
    async $$end(pageY: number) {
      if (!this.start) {
        return;
      }
      let endTime = new Date().getTime();
      // @ts-ignore
      const query = uni.createSelectorQuery().in(this);
      const top: number = await new Promise((resolve) => {
        query.select('#picker').boundingClientRect((data) => {
          endTime = new Date().getTime();
          resolve(data.top as number);
        })
          .exec();
      });
      const relativeY = top + this.bodyHeight / 2;

      this.end = pageY;

      // 如果上次时间距离松开手的时间超过 100ms, 则停止了, 没有惯性滑动
      if (endTime - this.startTime > 100) {
        // 如果end和start相差小于10,则视为
        if (Math.abs(this.end - this.start) > 10) {
          this.$$stop(this.end - this.start);
        } else {
          this.$$stop(relativeY - this.end);
        }
      } else {
        if (Math.abs(this.end - this.start) > 10) {
          const endPos = this.points.length - 1;
          let startPos = endPos;
          for (
            let i = endPos;
            i > 0 && this.startTime - this.points[i].time < 100;
            i--
          ) {
            startPos = i;
          }

          if (startPos !== endPos) {
            const ep = this.points[endPos];
            const sp = this.points[startPos];
            const t = ep.time - sp.time;
            const s = ep.y - sp.y;
            const v = s / t; // 出手时的速度
            const diff = v * 150 + (this.end - this.start); // 滑行 150ms,这里直接影响“灵敏度”
            this.$$stop(diff);
          } else {
            this.$$stop(0);
          }
        } else {
          this.$$stop(relativeY - this.end);
        }
      }

      this.start = 0;
    },
    $$stop(diff: number) {
      this.translate += diff;
      // 移动到最接近的那一行
      this.translate = Math.round(this.translate / this.rowHeight) * this.rowHeight;
      const max = getMax(this.offset, this.rowHeight);
      const min = getMin(this.offset, this.rowHeight, this.items.length);
      // 不要超过最大值或者最小值
      if (this.translate > max) {
        this.translate = max;
      }
      if (this.translate < min) {
        this.translate = min;
      }

      // 如果是 disabled 的就跳过
      let index = this.offset - this.translate / this.rowHeight;
      while (!!this.items[index] && this.items[index].disabled) {
        diff > 0 ? ++index : --index;
      }
      this.translate = (this.offset - index) * this.rowHeight;
      this.setTransition(0.3);
      this.setTranslate(this.translate);

      // 触发选择事件
      this.onChange.call(this, this.items[index], index);
    },
    onTouchstart(e: TouchEvent) {
      this.$$start(e.changedTouches[0].pageY);
    },
    onTouchMove(e: TouchEvent) {
      this.$$move(e.changedTouches[0].pageY);
      e.preventDefault();
    },
    onTouchEnd(e: TouchEvent) {
      this.$$end(e.changedTouches[0].pageY);
    },
  },

});
</script>

这里主要涉及到日期选择器逻辑的适配,主要通过触摸事件来控制选择器的滚动和选择。

  • $$start方法初始化触摸的开始位置和时间;

  • $$move方法处理触摸移动事件,计算移动的距离diff,并更新平移值。

    • 更新触摸终点,计算滑动距离;
    • 设置过渡时间为0,记录平移值(移动距离+当前平移值);
    • 更新开始时间;
    • 记录触摸点(当前时间、触摸点Y坐标),同时限制记录点数量;
  • $$end方法,处理触摸结束事件,计算最终的平移值并选择触发事件。

    • 若无触摸开始事件,直接返回;
    • 获取结束时间;
    • 通过uni.createSelectorQuery()获取选择器位置信息,计算出选择器中心的Y坐标;
    • 计算触摸时间;
      • 松开手距离上一次移动的时间超过100ms,认为停止,不执行惯性滑动;
      • 触摸时间小于100ms,根据触摸点的起始和结束两个位置,通过时间差和距离差计算出速度v;
      • v乘以惯性滑动事件(如150ms),计算出应该滑动的距离;
    • 重置触摸开始位置。

    通过这些步骤,处理触摸结束事件,计算最终的平移值,确保选择器能正确对齐到最近的选项,同时处理惯性滑动的效果。

  • $$stop方法,和end方法结合,确保选择器能正确对齐到最近的选项。

    • 更新平移值;

    • 移动到最近的一行,将平移值除以行高,四舍五入再乘以行高;

      1
      this.translate = Math.round(this.translate / this.rowHeight) * this.rowHeight;
    • 获取平移的最大值和最小值,限制平移值不会超出边界;

    • 设置过渡时间,更新平移值;

四、api与属性

H5的api与属性在小程序中存在不适配情况,以常见的例子进行举例:

4.1 storage

获取本地存储中的数据:

1
2
3
4
5
//H5
window.sessionStorage.getItem('SESSIONID')

//小程序
uni.getStorageSync('SESSIONID')

将数据存储到本地存储中:

1
2
3
4
5
//H5
sessionStorage.setItem('key', val)

//小程序
uni.setStorageSync('key', val)

还包括清空等各种方法,大同小异。

4.2 window

window对象提供了诸多属性和方法,但小程序没有,需要进行处理。

4.3 appid

在进行跳转的过程中,H5只需要path跳转路径即可,但小程序还需传入appid,方可进行正常跳转。

五、样式问题

样式问题一般都是一些小问题,遇到问题,大家也可以在网上进行搜索解决,我随便举两个问题吧。

  • a标签

    a标签在小程序上会变为navigator,有以下几种处理方法:

    • 使用条件编译;
    • 自定义组件;
    • 使用uni.navigateTo方法;
  • div标签

    这边建议div标签全部换为view标签。

  • 标签设置宽高等属性

    直接在标签上(如img)设置宽高,会失效,推荐使用css样式表来进行样式管理。

  • 图标
    H5图标使用svg标签内嵌font-size来控制大小,但小程序不支持svg标签渲染,可以使用ui组件库或者将图标转为base64,然后人工调整大小。

    举个例子吧:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <template>
    // 原先H5
    <svg width="87px" height="63px" class="weui-inline-block weui-icon-svg"

    // 兼容处理
    <view>
    <svg v-if="isH5" width="87px" height="63px" class="weui-inline-block weui-icon-svg" :style="{'font-size':pxToRem(size) }"viewBox="0 0 87 63" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <title>路径</title>

    <img v-if="!isH5" class="weui-inline-block weui-icon-svg tick-mp" src="data:image/png;base64,iVBORw0KGgoAAAANSUh..."/>
    </view>
    </template>
    <style lang="less" scoped>
    .tick-mp {
    height: xx rem;
    width: xx rem;
    }
    </style>
  • 伪元素

    小程序对伪元素的支持度不够友好,对常见的伪元素(如::before)提供支持,但较新或较复杂的css特性(如::first-line)可能不支持,使用时需查阅最新文档/实际试验。