0%

需要迁移的小程序是原生的微信小程序,涉及大量微信小程序的原生api和功能,工作量不小。

首先还是要用到迁移工具Antmove,可惜这个工具已经多年未更新,生成的代码必然是没法直接运行的,而且后来的使用中发现,有一些支付宝原本不支持的功能做了降级适配,实际上现在已经支持了,而Antmove做的处理反而会出问题。

基础的迁移操作和差异对比可参考以下文章:

一些微信小程序和支付宝小程序对应的差异

微信小程序迁移到支付宝记坑

这边只记录我遇到的问题:

样式隔离

目前只能做到自身不影响外部,但外部始终能影响内部。

因此所有自定义组件的样式如果起得过于简单,一旦冲突,都需要重新命名。

如果有的页面样式很多,其中有些希望影响内部,有些又不希望影响的话,只能重命名那些不希望影响组件的,确保样式名唯一。

页面样式隔离

如何防止页面样式影响到组件内部?

样式兼容

background-image不支持,需要改为image组件写法。

获取子组件 selectComponent

Antmove抹平了查找子组件的差异,从而保留了selectComponent(s)方法。

由于要尽量保留微信原生写法,这里后面提到的获取子组件都指的是包装过的selectComponent

生命周期

在生命周期的onLoad和onShow时不能使用查找子组件,此时拿不到对象,需要套一层setTimeout(() => {}, 0)

抽象节点

无法获取到抽象节点,只能改写法,使用ref来实现,参考如下:

1
2
<abstracta:for='{{list}}'ref="refNavList"id='nav_list'a:key='{{categoryId}}'a:if='{{index===selected}}'item='{{item}}'ref-numbers='{{list}}'onOnClickCard='onClickCard'>
</abstract>
1
2
3
4
5
6
7
8
9
10
addMore(){
constlist=this.$navList
if(list){
list.addMore()
}
},
refNavList(ref){
//存储自定义组件实例,方便以后调用
this.$navList=ref;
},

子组件的点击

直接写<foo onTap="onTap">是无法触发的。需要在自定义组件外面套一层view再加点击事件,或者自定义组件做转发:

1
<view class="子组件最外层的view" onTap="onTap">...</view>
1
2
3
onTap (e) {
this.triggerEvent('tap', e);
},

注意:自定义组件的事件(如 onTap 等),并不是每个自定义组件默认支持的,需要自定义组件本身明确支持才能使用。

npm的使用

在微信中使用npm库,开发工具会将npm库的入口文件重新打包输出并放到转换目录对应文件夹的根路径下。

而在支付宝中,npm包在引入后不会出现转换目录,而是直接使用node_modules下的代码了。

因此如果代码中出现了对npm的引用,其引入路径全部都需要更改。

考虑到有些npm库的代码也是原生微信小程序的,同样需要转换代码,最终的处理方案是将所有npm包都手动拷贝到指定目录,一起参与antmove的转换,并手动修改所有涉及的引入路径。

Behavior

支付宝中不存在该对象,只能用Mixin对象和mixins属性平替。

复制到剪贴板api

不知道出于什么实际原因,文档里虽然还有,但该接口已废弃。小程序中如果有类似需求,只能舍弃。

目前my.setClipboard这个api支付宝开放平台文档已下架,已不支持使用

my.setClipboard 在预览和真机调试下无效

查找元素 createSelectorQuery

与微信不同,不支持查询子组件内部元素的位置信息,只能查原生组件的。

所以需要改变写法,先在子组件上实现一个createSelectorQuery相关逻辑,然后让父组件用selectComponent获取子组件,再执行其方法获取结果。

另外Antmove对createSelectorQuery做了兼容性处理,因为早期的支付宝原生方法不支持局部查询,所以使用this.createSelectorQuery时也会变成全局查询。现在的版本需要移除此适配。

可选操作符

比如a?.ba ?? b语法目前都不支持,据说2023年6月后会支持。

目前只能手动改成&&||

导航栏

不能隐藏返回、首页按钮

不能完全自由地用脚本控制返回、首页按钮的显隐。因此,原本在微信上自定义过返回、首页按钮的自定义导航栏,只能改回系统按钮。

以下是官方回复:

Q:导航栏左上角的 “返回小程序首页” 按钮和 “返回上一页” 按钮何时会展示?

A:当页面为最底层页面(页面栈深度为 1 ),且页面为非首页、非 tabBar 页面时,标题栏左上角默认展示 “返回首页” 按钮;
当页面栈深度大于 1 时,默认展示 “返回上一页” 按钮
页面栈 是小程序框架管理界面的方式,可以使用 my.getCurrentPages().length 查看当前页面栈深度。

Q:如何隐藏标题栏上的返回按钮?

A:暂无 API 可以直接隐藏页面的返回上一页按钮;可以先通过 my.reLaunch 进行页面跳转,使用页面栈深度为 1,返回上一页按钮自然隐藏。如有必要,在目标页面里调用 my.hideBackHome,将返回小程序首页按钮也隐藏掉。

而用于获取左侧按钮和右侧菜单按钮的api写得像是一坨屎,有很多历史包袱,且没有官方示例。

以下是我的代码供参考:

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
export function getHeader () {
const {
titleBarHeight,
statusBarHeight,
} = wx.getSystemInfoSync()
let titleBarLeft = 30
if (wx.canIUse('getLeftButtonsBoundingClientRect')) {
const {
backButtonIcon,
backButtonInteractive,
homeButtonIcon,
} = wx.getLeftButtonsBoundingClientRect();
titleBarLeft = 0
if (homeButtonIcon) {
titleBarLeft += homeButtonIcon.right
} else if (backButtonInteractive) {
titleBarLeft += backButtonInteractive.right
}
}
return {
titleBarHeight,
statusBarHeight,
titleBarLeft,
}
}

不能自定义前景色

首先,在页面的json配置文件中只能修改背景色backgroundColor。微信上的前景色属性titleBarColor并不会生效。

而背景色只要不为白色#FFFFFF时,前景色就会被强制固定为白色。

我这里原本的UI配色是将背景色设置为较浅的灰色#F7F7F7,结果标题文字变成白色完全看不清。

解决方案是把导航栏的颜色配置挪到onLoad时用脚本修改:

1
2
3
4
wx.setNavigationBar({
backgroundColor:'#f7f7f7',
frontColor:'#333333',
})

然后又发现前景色frontColor实际上只接受黑白两种色值。
这个写法会提示不合法,只能把frontColor改为#000000

1
2
3
4
wx.setNavigationBar({
backgroundColor: '#f7f7f7',
frontColor: '#000000',
})

Antmove处理

mixins

支付宝现在已原生支持,但antmove做了适配,反而导致不兼容。

需要到__antmove/component/classSubdirectory/component.js找到以下代码,注释掉对options.mixins属性的删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const behaviors = options.behaviors || []
const mixins = options.mixins || []
const _export = options.export || ''
delete options.behaviors
// delete options.mixins
const retMixins = {}

_opts.observerObj = {}
_opts.observersObj = {}
_opts.behaviorsArr = []

processBehavior(retMixins, behaviors, _opts.behaviorsArr)
processBehavior(retMixins, mixins, _opts.behaviorsArr)
mergeOptions(retMixins, options)
processBehaviorId(behaviors)
processBehaviorId(mixins)

createSelectorQuery

支付宝现在已原生支持,但antmove做了适配,反而导致不兼容。

需要到__antmove/component/classSubdirectory/component.js找到以下代码,注释掉对createSelectorQuery的包装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fnApp.insert('onInit', function() {
this.__wxExparserNodeId__ = nextUid()
// processIntersectionObserver(this)
// this.createSelectorQuery = function() {
// if (config.env !== 'production') {
// console.warn(
// '支付宝createSelectorQuery不支持限定选择器的选择范围,如使用,请保证对应选择器使用的唯一性',
// )
// }
// return createSelectorQuery.fn()
// }
for (const method in this) {
if (typeof this[method] === 'function') {
this[method] = this[method].bind(this)
}
}

客服按钮

微信现在有好几种形式发起客服,比如企业微信,但支付宝下只有一种,就是放个按钮让用户点击。

需要覆写这个按钮的样式才能实现UI适配,否则很丑。

为了有一个清晰且低耦合的页面结构,更好的做法是自定义样式后,把一个透明的客服按钮盖在上面。

这样客服按钮可以有多种表现形式,而接入写法很简单,在响应范围最外层加上contact-button-wrapper样式,再在内部最末尾加上contact-button即可。

参考如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
.contact-button-wrapper {
position: relative;
}

.contact-button-wrapper contact-button {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
opacity: 0;
z-index: 1;
}
1
2
3
4
5
6
7
<view class="contact-button-wrapper">
如遇到定位不准确等问题,请<text class='text-primary'>联系客服</text>
<contact-button
tnt-inst-id="你的客服id"
scene="你的客服scene"
/>
</view>

分享 showShareMenu

在微信中由于限制滥用,从某个时间点起这个按钮已经不具备弹出分享页面的功能了,只是控制右上角分享按钮是否展示。

用户只能通过open-type的按钮和右上角来手动操作打开分享页面。

但是文档始终没有修改,容易引起误会。

但支付宝中,这个api仍然可以唤起分享页面,并且没有操作来源的限制。

而且文档删除了showShareMenu,改名为showSharePanel,但前者仍然可用。

因此在微信中习惯性地在onShow回调里打开右上角分享按钮,在支付宝中会变成弹出分享页面。

需要删除该语句。

数据变化观测器 observer

支付宝已原生支持。

但某些场景的表现与微信有不同,需要仔细检查。比如:

页面属性a有一个数组子属性b,这个数组b被子组件监听。

当数组成员b[c]被修改时,observe不会触发。

数组被重新赋值b = [...b],也不会触发。

页面属性被旧对象组装后重新赋值a = {...a, b},也不会触发。

只有该页面属性被全新对象赋值时才会触发。(怀疑跟diff算法有关)

因此在所有值变化结束后,需要靠lodash.deepCopy复制对象,然后重新setData。

简介

FatFramework即传统的Framework大包,一个二进制文件同时包含了真机和模拟器在不同架构下的代码,是以前闭源分发常用的形式。

XCFramework本质上是一个文件夹合集,里面包含了真机和模拟器两个子文件夹,再里面才是Framework本体。另外还有一个Info.plist配置文件,在xcode11之后可以用来将当前环境所需的指令集自动对应到指定的Framework。

现在cocoapods上很多闭源库都已经使用XCFramework取代了FatFramework。我们不能落后于时代,必须了解一下这一实现流程。

打包方式

首先在编译阶段,需要确保真机和模拟器的产出包含了所需的archs。

我们参考谷歌的GoogleAppMeasurement.xcframework,打开它的目录,可以看到如下两个文件夹:

1
2
ios-arm64_armv7
ios-arm64_i386_x86_64-simulator

可以看到真机环境所需的archs是arm64armv7,模拟器环境所需的archs是arm64i386x86_64

因为现在M1环境的模拟器arch是arm64,模拟器需要包含arm64,这是以前很多教程没提及的。

如果你使用的是x86环境,在编译模拟器时,需要手动指定archs,否则不会包含arm64.

顺便一提,一些教程和问题解答会让xcode12和x86的用户在项目配置里设置Excluded Architectures的模拟器配置项为arm64。这也会让模拟器编译默认排除arm64

我们不需要这么做。

我用的是fastlane构建,不会影响项目本身的配置,fastlane脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
desc "打包SDK"
lane :buildSDK do
customScheme = "MySDK"
xcbuild(
workspace: "MySDK.xcworkspace",
scheme: customScheme,
clean: true,
configuration: "Release",
xcargs: "-sdk iphonesimulator ARCHS='arm64 x86_64 i386' "
)

xcbuild(
workspace: "MySDK.xcworkspace",
scheme: customScheme,
clean: true,
configuration: "Release",
xcargs: "-sdk iphoneos ARCHS='arm64 armv7' "
)
end

这样我们在执行fastlane构建时,可以在真机和模拟器下获得各自所需的原始Framework。

接下来是将这两个Framework合并打包。

XCFramework的打包脚本非常简单:

1
2
# 使用xcodebuild -create-xcframework命令合并
xcodebuild -create-xcframework -framework "${DEVICE_DIR}" -framework "${SIMULATOR_DIR}" -output "${Merged_DIR}"

前两个参数即真机和模拟器的Framework路径,第三个是产出路径。

传统的FatFramework打包的麻烦之处在于,两个Framework如果包含了重复的arch就会报错,因此需要在合并前,先移除重复的部分。

1
2
3
4
5
6
# 复制真机Framework作为新产出Framework的原型
cp -r ../dist/${PRODUCT_NAME}/iphoneos/ ${MergePath}
# 移除模拟器的arm64并替换原始文件
lipo "${SIMULATOR_DIR}/${PRODUCT_NAME}" -remove arm64 -output "${SIMULATOR_DIR}/${PRODUCT_NAME}"
# 使用lipo -create命令合并,需要指向二进制文件,而不是framework文件夹
lipo -create "${DEVICE_DIR}/${PRODUCT_NAME}" "${SIMULATOR_DIR}/${PRODUCT_NAME}" -output "${MergePath}${PRODUCT_NAME}.framework/${PRODUCT_NAME}"

这样就在产出目录下同时生成了FatFramework和XCFramework。

最后可以使用lipo -info ${Framework二进制文件路径}来验证。

使用方式

在podspec文件中引入Framework时,直接将以前的.framework改为.xcframework就可以了。

完整示例

完整代码如下,将其保存为post_build.sh。
在项目target的Build Phases的末尾创建一个Run Script Phase并执行该脚本,就可以在fastlane脚本执行完毕后自动打包。

1
2
3
4
5
echo "run script begin"
script_path=${SRCROOT}/post_build.sh
chmod 777 $script_path
# 调用sh脚本文件
sh $script_path
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
#!/bin/sh

if [ $CONFIGURATION == Release ]
then
echo 'release build'
#exit 0
else
echo 'debug build'
exit 0
fi

# 获取包路径
rootPub=../dist/${PRODUCT_NAME}/iphoneos/

if [ $EFFECTIVE_PLATFORM_NAME == -iphonesimulator ]
then
echo '模拟器'
rootPub=../dist/${PRODUCT_NAME}/iphonesimulator/
else
echo '真机'
fi

# 将包从默认输出路径复制到dist目录
rm -rf ${rootPub}
mkdir -p ${rootPub}

cp -r ${CODESIGNING_FOLDER_PATH} ${rootPub}

# 合并真机模拟器sdk
DEVICE_DIR=../dist/${PRODUCT_NAME}/iphoneos/${PRODUCT_NAME}.framework
SIMULATOR_DIR=../dist/${PRODUCT_NAME}/iphonesimulator/${PRODUCT_NAME}.framework
# 如果真机包或模拟包不存在,则退出合并
if [ ! -d "${DEVICE_DIR}" ] || [ ! -d "${SIMULATOR_DIR}" ]
then
echo "真机包或模拟包不存在,退出合并"
exit 0
fi

MergePath=../dist/${PRODUCT_NAME}/merge/
rm -rf ${MergePath}
mkdir -p ${MergePath}

Merged_DIR=../dist/${PRODUCT_NAME}/merge/${PRODUCT_NAME}.xcframework
xcodebuild -create-xcframework -framework "${DEVICE_DIR}" -framework "${SIMULATOR_DIR}" -output "${Merged_DIR}"
cp -r ../dist/${PRODUCT_NAME}/iphoneos/ ${MergePath}
lipo "${SIMULATOR_DIR}/${PRODUCT_NAME}" -remove arm64 -output "${SIMULATOR_DIR}/${PRODUCT_NAME}"
lipo -create "${DEVICE_DIR}/${PRODUCT_NAME}" "${SIMULATOR_DIR}/${PRODUCT_NAME}" -output "${MergePath}${PRODUCT_NAME}.framework/${PRODUCT_NAME}"

# copy assets Bundle: disable copy bundle
rm -rf ${DWARF_DSYM_FOLDER_PATH}/${PRODUCT_NAME}.bundle/*.plist
rm -rf ${DWARF_DSYM_FOLDER_PATH}/${PRODUCT_NAME}.bundle/_CodeSignature
cp -r ${DWARF_DSYM_FOLDER_PATH}/${PRODUCT_NAME}.bundle ${MergePath}

### 清理资源
rm -rf ../${PRODUCT_NAME}/Frameworks/*
mv ${MergePath}/* ../${PRODUCT_NAME}/Frameworks/
rm -rf ../dist/${PRODUCT_NAME}/

除了能在AppStore下到或者可以轻易获取的工具,我的iOS开发环境还需要以下环境支持:

  • homebrew
  • cocoapods
  • fastlane
  • xcode的arm64模拟器

homebrew

原始的写法因为无法访问raw.githubusercontent.com,可能导致安装卡住或者失败。

经过一番踩坑,最终找到了这个网站:

https://brew.idayer.com/

使用以下命令分别安装arm版和x86版的homebrew:

1
/bin/bash -c "$(curl -fsSL https://cdn.jsdelivr.net/gh/ineo6/homebrew-install/install.sh)"
1
arch -x86_64 /bin/bash -c "$(curl -fsSL https://cdn.jsdelivr.net/gh/ineo6/homebrew-install/install.sh)"

arm版会被安装到/opt/homebrew/bin/brew目录,x86版会被安装到/usr/local/bin/brew目录。

安装成功后,会有提示需要手动执行一段脚本:

1
2
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"

可以手动编辑用户根目录下的.zprofile文件,确保含有如下内容:

1
2
3
4
eval "$(/opt/homebrew/bin/brew shellenv)"
export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles/bottles
alias abrew='arch -arm64 /opt/homebrew/bin/brew'
alias ibrew='arch -x86_64 /usr/local/bin/brew'

保存后再执行source ~/.zprofile刷新配置。

此时,输入brew -vibrew -vabrew -v可以看到如下信息:

1
2
3
4
5
6
7
8
9
10
11
mirari@MBA ~ % brew -v
Homebrew 3.2.4
Homebrew/homebrew-core (git revision 64a2874e87; last commit 2021-07-27)
Homebrew/homebrew-cask (git revision 9d5abe6a67; last commit 2021-07-27)
mirari@MBA ~ % ibrew -v
Homebrew 3.2.5
Homebrew/homebrew-core (git revision 507807b9dc; last commit 2021-07-27)
mirari@MBA ~ % abrew -v
Homebrew 3.2.4
Homebrew/homebrew-core (git revision 64a2874e87; last commit 2021-07-27)
Homebrew/homebrew-cask (git revision 9d5abe6a67; last commit 2021-07-27)

说明安装正常。

之前可能因为网络等原因,我的homebrew虽然安装成功了,但安装大多数工具,比如bob和fastlane,都会提示找不到叫这个名字的工具。

错误信息为Could not resolve HEAD to a revision

使用brew -v可以看到,homebrew-core没有HEAD。

执行以下命令可以修复:

1
git -C $(brew --repository homebrew/core) reset --hard HEAD.

但是为了稳妥起见,我还是rm -rf了两个brew的目录重新安装了一遍。

fastlane

homebrew安装成功后,直接运行brew install fastlane即可安装成功。

cocoapods

之前有段文字提示brew上的cocoapods已经停止更新,需要改用gem安装,现在去看已经没有了,brew上直接运行brew install cocoapods即可。

M1芯片的arm64模拟器

这是最坑的部分。

新鲜出炉的M1环境xcode在运行以前的老项目时,遇到了一堆问题,比如这样的错误信息:

1
building for iOS Simulator-x86_64 but attempting to link with file built for iOS Simulator-arm64

或者提示找不到对应的Framework,或者提示在当前arch下当前的Symbol无法识别。

解决方案1:

在访达的应用程序列表中,找到xcode,右键选择显示简介,然后勾选“使用Rosetta打开”。

简单粗暴无脑。这样启动的xcode,模拟器就是x86的了,完美适配x86环境。包括那些没有在xcframework的模拟器里编译arm64的依赖都可以正常运行。

但是xcode的执行效率会受影响,我不能接受。

顺带一提,直接从官网下载的Mac版IntelliJ IDEA就是x86版的,在M1环境下运行卡顿很严重,用了一天整个人都难受了,插件Power Mode II的打字特效掉帧严重,不得不关闭。需要在官网下载前,点击下载按钮右侧切换到arm版,从此丝般顺滑。

我自己制作的xcframework包在模拟器环境同时包含了x86_64arm64(谷歌就是这样),要是M1芯片还跑x86,不是媚眼抛给瞎子看?

但是要让项目在arm64下跑起来,配置也不容易,经过一番尝试,解决方案大致如下:

解决方案2:

这里涉及3个编译目标,分别是:

  • pod依赖的第三方库Target,包括源码和Framework
  • 本地用于编译Framework的自有库源码Target
  • 调用以上依赖并运行在模拟器或真机上的应用Target
运行在模拟器或真机上的应用Target

首先确保用于调用Framework和运行模拟器的应用项目Target本身的Build Settings配置如下:

  • Architectures为默认值$(ARCHS_STANDARD)

在M1芯片下,模拟器的默认值应该是arm64, armv7

  • Build Active Architecture Only的Debug为Yes(前提是模拟器用Debug方式运行)

这一步的作用是让模拟器编译目标时只编译arm64的代码,不要尝试x86等arch的编译。因为它依赖的pod和自有库的arch会被指定为只有一个arm64,如果尝试其他arch编译就会报错。

  • 删除Excluded Architectures

这一步的作用是让pod来管理此项参数,不要手动设置值。手动设置这项值时,pod安装会有一段警告提示。

1
2
3
[!] The `Demo [Debug]` target overrides the `EXCLUDED_ARCHS[sdk=iphonesimulator*]` build setting defined in `../Pods/Target Support Files/Pods-Demo/Pods-Demo.debug.xcconfig'. This can lead to problems with the CocoaPods installation
- Use the `$(inherited)` flag, or
- Remove the build settings from the target.
  • 删除Valid Architectures

xcode12的新项目应该默认移除了此项参数。新版xcode不再需要用这个参数来指定archs。

pod依赖的第三方库Target

在podfile中添加钩子

1
2
3
4
5
6
7
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['ONLY_ACTIVE_ARCH'] = 'NO'
end
end
end

因为手动在xcode里修改pod的project是没用的,pod install一执行,一切修改都会还原。因此需要用钩子来处理pod安装的配置项。

这一步的作用是让pod在编译时编译所有archs,也就是包括了x86和arm64.

之所以要这样做,是因为模拟器环境的pod安装时仍然会把开源的依赖以x86形式编译(我推测),也就导致了上面那个building for iOS Simulator-x86_64 but attempting to link with file built for iOS Simulator-arm64错误。因此我们用这段语句让pod强制编译所有arch。

本地用于编译Framework的自有库源码Target

其余配置参考上面的应用Target,但是Build Active Architecture Only这一项需要改为NO,也就是手动操作了上面pod的那段配置。

最后,记得删除pods目录,删除项目缓存,然后再执行编译。

解决方案3

其他操作都类似方案2,但是将pod和自有库的那个Build Active Architecture Only配置项,改为操作Excluded Architectures。具体操作为:

pod依赖的第三方库Target

在podfile中添加钩子

1
2
3
4
5
post_install do |installer|
installer.pods_project.build_configurations.each do |config|
config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64"
end
end
本地用于编译Framework的自有库源码Target

Debug下的Build Active Architecture Only保持YES,但是Excluded Architectures设置为arm64。

这一步的作用是让Pod依赖和自有库模拟器编译时,排除arm64。

出处:https://stackoverflow.com/a/63955114/6096307

按理说这样arm64的模拟器运行时应该会报错,但是实际上确实可以执行,我也不知道为什么了╮(╯▽╰)╭

不过虽然应用本身能正常运行,但自有库还需要在打包时包含所有的archs。之前在pod里面过滤了arm64,如果自有库依赖了pod中的有源码的开源库,就会导致编译打包时丢失arm64的arch,从而报错。因此如果项目本身是单纯的应用,使用方案3排除arm64会更节省资源,编译更快速。如果是工具库,就得使用方案2,编译所有archs。

vue-fullscreen

一个用于将任意页面元素进行全屏切换的vue组件,基于 screenfull.js

有任何问题请到github项目页提交issue,博客的留言我可能无法及时看到,谢谢各位支持👍。

npm version language

npm version language

npm download license

vue-fullscreen for vue2

在线演示

使用示例

支持

浏览器支持

注意: 在IE浏览器下使用需要先实现Promise的polyfill.

注意: Safari浏览器在桌面和iPad下支持,但iPhone不支持.

注意: 当处在全屏模式中,浏览其他页面,切换标签页,或者切换到其他应用(例如使用 Alt-Tab)也会导致退出全屏模式。

了解更多

安装

使用npm命令安装

1
npm install vue-fullscreen@next

使用

引入vue-fullscreen,并使用app.use()注册插件,之后即可使用。

组件、指令和api会被一起安装到app全局。

1
2
3
4
5
6
7
import { createApp } from 'vue'
import VueFullscreen from 'vue-fullscreen'
import App from './App.vue'

export const app = createApp(App)
app.use(VueFullscreen)
app.mount('#app')
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
<template>
<div ref="root">
<!-- Component -->
<fullscreen v-model="fullscreen">
content
</fullscreen>
<!-- Api -->
<button type="button" @click="toggleApi" >FullscreenApi</button>
<!-- Directive -->
<button type="button" v-fullscreen >FullscreenDirective</button>
</div>
</template>
<script lang="ts">
import {
ref,
defineComponent,
toRefs,
reactive
} from 'vue'
export default defineComponent({
methods: {
toggleApi () {
this.$fullscreen.toggle()
}
},
setup () {
const root = ref()
const state = reactive({
fullscreen: false,
})
function toggle () {
state.fullscreen = !state.fullscreen
}
return {
root,
...toRefs(state),
toggle
}
}
})
</script>

注意: 由于浏览器的安全限制,全屏切换必须由一个用户操作事件发起,比如clickkeypress

以api形式使用

在Vue组件实例中,可以直接调用this.$fullscreen来获取全屏api。

1
this.$fullscreen.toggle()

或者你可以单独引入api然后执行它。

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
<template>
<div ref="root">
<div class="fullscreen-wrapper">
Content
</div>
<button type="button" @click="toggle" >Fullscreen</button>
</div>
</template>
<script lang="ts">
import {
ref,
defineComponent,
toRefs,
reactive,
} from 'vue'
import { api as fullscreen } from 'vue-fullscreen'
export default defineComponent({
setup() {
const root = ref()
const state = reactive({
fullscreen: false,
teleport: true,
})

async function toggle () {
fullscreen.toggle(root.value.querySelector('.fullscreen-wrapper'), {
teleport: state.teleport,
callback: (isFullscreen) => {
state.fullscreen = isFullscreen
},
})
}

return {
root,
...toRefs(state),
toggle,
}
},
})
</script>

方法 & 属性

toggle([target, options, force])

切换全屏模式。

  • target:
    • 类型: Element
    • 默认值: document.body
    • 全屏操作的目标元素。
  • options (可选):
    • 类型: Object
    • 配置项,详见下文。
  • force (可选):
    • 类型: Boolean
    • 默认值: undefined
    • 传入true可以指定进入全屏模式,效果与request方法相同,false反之。

request([target, options])

进入全屏模式。

  • target:
    • 类型: Element
    • 默认值: document.body
    • 全屏操作的目标元素。
  • options (optional):
    • 默认值: Object
    • 配置项,详见下文。

exit()

退出全屏模式。

注意: 这些方法都会返回一个Promise对象,你可以在Promise执行完成后获取状态,或者在options中传入一个回调函数来获取。

1
2
3
4
async toggle () {
await this.$fullscreen.toggle()
this.fullscreen = this.$fullscreen.isFullscreen
}

isFullscreen

判断是否处于全屏状态。

  • 类型: Boolean

注意: 唤起全屏的动作是异步的,在调用方法后你无法立即获取预期的结果。

isEnabled

判断环境是否支持全屏API。

  • 类型: Boolean

element

获取当前全屏的元素

  • 类型: Element | null

Options

callback

  • 类型: Function
  • 默认值: null

当全屏状态变更时执行。

fullscreenClass

  • 类型: String
  • 默认值: fullscreen

这个样式会在进入全屏状态时被添加到目标元素上。

pageOnly

  • 类型: Boolean
  • 默认值: false

如果为true,不调用全屏API,而是将当前元素撑满网页。

注意: 如果浏览器不支持全屏API,这个选项默认值为true.

teleport

  • 类型: Boolean
  • 默认值: true

如果为true, 进入全屏时目标元素会被移动到document.body下。

这可以避免一些弹窗在全屏模式下看不到的问题。

以指令形式使用

你可以使用v-fullscreen使任意元素拥有点击切换全屏的效果

1
<button v-fullscreen>FullScreen</button>

或者你可以单独引入指令并安装它。

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
<template>
<div>
<div class="fullscreen-wrapper">
Content
</div>
<button type="button" v-fullscreen.teleport="options" >Fullscreen</button>
</div>
</template>
<script lang="ts">
import {
ref,
defineComponent,
toRefs,
reactive
} from 'vue'
import { directive as fullscreen } from 'vue-fullscreen'
export default defineComponent({
directives: {
fullscreen
},
setup () {
const root = ref()
const state = reactive({
options: {
target: ".fullscreen-wrapper",
callback (isFullscreen) {
console.log(isFullscreen)
},
},
})
return {
root,
...toRefs(state),
toggle
}
}
})
</script>

修饰符

pageOnly

不调用全屏API,而是将当前元素撑满网页。

teleport

进入全屏时目标元素会被移动到document.body下。

这可以避免一些弹窗在全屏模式下看不到的问题。

配置项

target

  • 类型: String | Element
  • 默认值: document.body

全屏操作的目标元素。可以使用样式选择器字符串来指定元素,相当于document.querySelector(target)。注意直接传递元素对象时,需要确保该元素已存在。指令被初始化时,当前组件的内部元素可能尚未初始化。

callback

  • 类型: Function
  • 默认值: null

当全屏状态变更时执行。

fullscreenClass

  • 类型: String
  • 默认值: fullscreen

这个样式会在进入全屏状态时被添加到目标元素上。

以组件形式使用

你可以单独引入全屏组件并局部注册它。

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
<template>
<div>
<fullscreen v-model:fullscreen="fullscreen" :teleport="teleport" :page-only="pageOnly" >
Content
</fullscreen>
<button type="button" @click="toggle" >Fullscreen</button>
</div>
</template>

<script lang="ts">
import {
defineComponent,
toRefs,
reactive,
} from 'vue'
import { component } from 'vue-fullscreen'

export default defineComponent({
name: 'ComponentExample',
components: {
fullscreen: component,
},
setup() {
const state = reactive({
fullscreen: false,
teleport: true,
pageOnly: false,
})
function toggle() {
state.fullscreen = !state.fullscreen
}

return {
...toRefs(state),
toggle,
}
},
})
</script>

Props属性

fullscreen-class

  • 类型: String
  • 默认值: fullscreen

全屏组件的样式类,只有全屏时才生效。

exit-on-click-wrapper

  • 类型: Boolean
  • 默认值: true

如果为true, 点击全屏组件的空白部分会退出全屏。

page-only

  • 类型: Boolean
  • 默认值: false

如果为true,不调用全屏API,而是将当前组件撑满网页。

注意: 如果浏览器不支持全屏API,这个选项默认值为true.

teleport

  • 类型: Boolean
  • 默认值: true

如果为true, 进入全屏时当前组件会被移动到document.body下。

这可以避免一些弹窗在全屏模式下看不到的问题。

事件

change

  • isFullscreen: 当前的全屏状态。

在全屏状态改变时触发。

插件配置项

name

  • 类型: String
  • 默认值: fullscreen

如果你需要避免重名冲突,可以像这样引入:

1
2
3
4
5
6
7
8
9
import { createApp } from 'vue'
import VueFullscreen from 'vue-fullscreen'
import App from './App.vue'

export const app = createApp(App)
app.use(VueFullscreen, {
name: 'fs',
})
app.mount('#app')
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
<template>
<div ref="root">
<!-- Component -->
<fs v-model="fullscreen">
content
</fs>
<!-- Api -->
<button type="button" @click="toggleApi" >FullscreenApi</button>
<!-- Directive -->
<button type="button" v-fs >FullscreenDirective</button>
</div>
</template>
<script lang="ts">
import {
ref,
defineComponent,
toRefs,
reactive
} from 'vue'
export default defineComponent({
methods: {
toggleApi () {
this.$fs.toggle()
}
},
setup () {
const root = ref()
const state = reactive({
fullscreen: false,
})
function toggle () {
state.fullscreen = !state.fullscreen
}
return {
root,
...toRefs(state),
toggle
}
}
})
</script>

项目中之前一直使用husky进行代码质量控制,主要包含两个步骤:

  • 代码提交前,先执行eslint校验,不通过则不允许提交。
  • commit记录需要符合规范,用于自动生成changelog。

Husky6的配置步骤如下:

安装husky(lint-staged和commitlint的安装略)

确保package.json的依赖包含:

1
"husky": "^6.0.0",

执行husky安装命令

1
husky install

在项目目录下会出现一个.husky文件夹,包含一个.gitignore文件和一个_目录,不要改动它们,是存放husky脚本的。

添加hook动作

根据教程添加命令,或者在.husky目录直接创建以下这两个文件:

commit-msg

1
2
3
4
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx --no-install commitlint --edit $1

用于commit内容校验

pre-commit

1
2
3
4
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx lint-staged

用于代码eslint校验

注意文件名不可自定义,必须对应githook的阶段。

如果是初次配置,到这一步就已经可以正常运行了。

将husky安装命令配置为自动执行

在package.json的scripts中添加以下内容:

1
"prepare": "husky install",

注意脚本名必须是prepare,这样使用npm、pnpm执行install命令时,这条命令会在安装完成时自动执行。

当代码上传到远程,同步到另一台设备时,安装完依赖就可以自动启用husky了,无需关心上面那些复杂动作。

但是我遇到了这样的问题:

所有配置都完全一致,执行husky安装命令提示安装成功,没有错误信息,但githook就是不触发。

原因是hook文件的权限不足。在windows上创建的文件提交并同步到mac上就会出现此类问题,因为不是使用husky add命令创建的。

解决方式很简单,只要执行以下命令即可:

1
2
chmod +x .husky/pre-commit
chmod +x .husky/commit-msg

官网参考:

https://typicode.github.io/husky/#/?id=hooks-not-running

v-viewer

用于图片浏览的Vue3组件,支持旋转、缩放、翻转等操作,基于viewer.js

有任何问题请到github项目页提交issue,博客的留言我可能无法及时看到,谢谢各位支持👍。

npm version language

npm version language

npm download license

v-viewer for vue2

在线演示

使用示例

安装

使用npm命令安装

1
npm install v-viewer@next

使用

引入v-viewer及必需的css样式,并使用app.use()注册插件,之后即可使用。

组件、指令和api会被一起安装到app全局。

1
2
3
4
5
6
7
import { createApp } from 'vue'
import App from './App.vue'
import 'viewerjs/dist/viewer.css'
import VueViewer from 'v-viewer'
const app = createApp(App)
app.use(VueViewer)
app.mount('#app')
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
<template>
<div>
<!-- directive -->
<div class="images" v-viewer>
<img v-for="src in images" :key="src" :src="src">
</div>
<!-- component -->
<viewer :images="images">
<img v-for="src in images" :key="src" :src="src">
</viewer>
<!-- api -->
<button type="button" @click="show">Click to show</button>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
data() {
return {
images: [
"https://picsum.photos/200/200",
"https://picsum.photos/300/200",
"https://picsum.photos/250/200"
]
};
},
methods: {
show() {
this.$viewerApi({
images: this.images,
})
},
},
})
</script>

支持UMD方式引入

Browser

1
2
3
4
5
6
7
<link href="//unpkg.com/viewerjs/dist/viewer.css" rel="stylesheet">
<script src="//unpkg.com/vue@next"></script>
<script src="//unpkg.com/viewerjs/dist/viewer.js"></script>
<script src="//unpkg.com/v-viewer@next/dist/index.umd.js"></script>
<script>
app.use(VueViewer.default)
</script>

CommonJS

1
var VueViewer = require('VueViewer')

AMD

1
require(['VueViewer'], function (VueViewer) {});

以指令形式使用

只需要将v-viewer指令添加到任意元素即可,该元素下的所有img元素都会被viewer自动处理。

你可以像这样传入配置项: v-viewer="{inline: true}"

如果有必要,可以先用选择器查找到目标元素,然后可以用el.$viewer来获取viewer实例。

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
<template>
<div>
<div class="images" v-viewer="{movable: false}">
<img v-for="src in images" :src="src" :key="src">
</div>
<button type="button" @click="show">Show</button>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { defineComponent } from 'vue'
import 'viewerjs/dist/viewer.css'
import { directive as viewer } from "v-viewer"
export default defineComponent({
directives: {
viewer: viewer({
debug: true,
}),
},
data() {
return {
images: [
"https://picsum.photos/200/200",
"https://picsum.photos/300/200",
"https://picsum.photos/250/200"
]
};
},
methods: {
show () {
const viewer = this.$el.querySelector('.images').$viewer
viewer.show()
}
}
})
</script>

指令修饰器

static

添加修饰器后,viewer的创建只会在元素绑定指令时执行一次。

如果你确定元素内的图片不会再发生变化,使用它可以避免不必要的重建动作。

1
2
3
<div class="images" v-viewer.static="{inline: true}">
<img v-for="src in images" :src="src" :key="src">
</div>
rebuild

默认情况下当图片发生变更时(添加、删除或排序),viewer实例会使用update方法更新内容。

如果你遇到任何显示问题,尝试使用重建来代替更新。

1
2
3
<div class="images" v-viewer.rebuild="{inline: true}">
<img v-for="src in images" :src="src" :key="src">
</div>

以组件形式使用

你也可以单独引入全屏组件并局部注册它。

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
<template>
<div>
<viewer :options="options" :images="images"
@inited="inited"
class="viewer" ref="viewer"
>
<template #default="scope">
<img v-for="src in scope.images" :src="src" :key="src">
{{scope.options}}
</template>
</viewer>
<button type="button" @click="show">Show</button>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import 'viewerjs/dist/viewer.css'
import { component as Viewer } from "v-viewer"
export default defineComponent({
components: {
Viewer,
},
data() {
return {
images: [
"https://picsum.photos/200/200",
"https://picsum.photos/300/200",
"https://picsum.photos/250/200"
]
};
},
methods: {
inited (viewer) {
this.$viewer = viewer
},
show () {
this.$viewer.show()
}
}
})
</script>

组件属性

images
  • Type: Array
trigger
  • Type: Object

你可以使用trigger来代替images, 从而传入任何类型的数据。

trigger绑定的数据发生变更,组件就会自动更新。

1
2
3
<viewer :trigger="externallyGeneratedHtmlWithImages">
<div v-html="externallyGeneratedHtmlWithImages"/>
</viewer>
rebuild
  • Type: Boolean
  • Default: false

默认情况下当图片发生变更时(添加、删除或排序),viewer实例会使用update方法更新内容。

如果你遇到任何显示问题,尝试使用重建来代替更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
<viewer
ref="viewer"
:options="options"
:images="images"
rebuild
class="viewer"
@inited="inited"
>
<template #default="scope">
<img v-for="src in scope.images" :src="src" :key="src">
{{scope.options}}
</template>
</viewer>

组件事件

inited
  • viewer: Viewer

监听inited事件来获取viewer实例,或者也可以用this.refs.xxx.$viewer这种方法。

以api形式使用

api形式只能使用modal模式。

你可以直接执行函数: this.$viewerApi({options: {}, images: []}) 来展现画廊, 而不需要自己来渲染这些img元素.

函数会返回对应的viewer实例.

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
<template>
<div>
<button type="button" class="button" @click="previewURL">URL Array</button>
<button type="button" class="button" @click="previewImgObject">Img-Object Array</button>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import 'viewerjs/dist/viewer.css'
import { api as viewerApi } from "v-viewer"
export default defineComponent({
data() {
sourceImageURLs: [
'https://picsum.photos/200/200?random=1',
'https://picsum.photos/200/200?random=2',
],
sourceImageObjects: [
{
'src':'https://picsum.photos/200/200?random=3',
'data-source':'https://picsum.photos/800/800?random=3'
},
{
'src':'https://picsum.photos/200/200?random=4',
'data-source':'https://picsum.photos/800/800?random=4'
}
]
},
methods: {
previewURL () {
// If you use the `app.use` full installation, you can use `this.$viewerApi` directly like this
const $viewer = this.$viewerApi({
images: this.sourceImageURLs
})
},
previewImgObject () {
// Or you can just import the api method and call it.
const $viewer = viewerApi({
options: {
toolbar: true,
url: 'data-source',
initialViewIndex: 1
},
images: this.sourceImageObjects
})
}
}
})
</script>

Viewer的配置项 & 方法

请参考viewer.js.

插件配置项

name

  • Type: String
  • Default: viewer

如果你需要避免重名冲突,可以像这样引入:

1
2
3
4
5
6
7
8
9
10
11
import { createApp } from 'vue'
import 'viewerjs/dist/viewer.css'
import VueViewer from 'v-viewer'
import App from './App.vue'

export const app = createApp(App)
app.use(VueViewer, {
name: 'vuer',
debug: true,
})
app.mount('#app')
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
<template>
<div>
<!-- directive name -->
<div class="images" v-vuer="{movable: false}">
<img v-for="src in images" :src="src" :key="src">
</div>
<button type="button" @click="show">Show</button>
<!-- component name -->
<vuer :images="images">
<img v-for="src in images" :src="src" :key="src">
</vuer>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
data() {
return {
images: [
"https://picsum.photos/200/200",
"https://picsum.photos/300/200",
"https://picsum.photos/250/200"
]
};
},
methods: {
show () {
// viewerjs instance name
const vuer = this.$el.querySelector('.images').$vuer
vuer.show()
// api name
this.$vuerApi({
images: this.images
})
}
}
})
</script>

defaultOptions

  • Type: Object
  • Default: undefined

如果你需要修改viewer.js的全局默认配置项,可以像这样引入:

1
2
3
4
5
6
7
8
9
10
11
12
import { createApp } from 'vue'
import 'viewerjs/dist/viewer.css'
import VueViewer from 'v-viewer'
import App from './App.vue'

export const app = createApp(App)
app.use(VueViewer, {
defaultOptions: {
zIndex: 9999
}
})
app.mount('#app')

你还可以在任何时候像这样修改全局默认配置项:

1
2
3
4
5
import VueViewer from 'v-viewer'

VueViewer.setDefaults({
zIndexInline: 2021,
})

有一个很简单的需求如下:

给你一段URL,在其中插入一些参数,并返回新的URL,如何实现?

这里的参数,有时候称为query,有时候称为params,一般称为search,指的是

http://www.example.com:80/path/to/myfile.html?key1=value1&key2=value2#SomewhereInTheDocument

中的key1key2

最近正在对接阿里云的金融级实人认证,在传递认证成功回调页时就遇到了这样一个问题,大厂有时也会考虑得不全面。

第一层: 直接拼

1
2
3
4
5
import qs from 'query-string'
function resolve (url) {
const params = qs.stringify({ a: 1, b: 2 }) // a=1&b=2
return url + '?' + params
}

实际业务中,90%的场景这样写没问题,但如果url的值是这样的:

1
http://taobao.com/?c=3&d=4

最终结果就是

1
http://taobao.com/?c=3&d=4?a=1&b=2

将这段URL中的search解析,得到的结果是

1
2
3
4
5
{
c: '3',
d: '4?a=1',
b: '2',
}

显然不符合预期。

第二层: 兼容已存在的search

1
2
3
4
5
import qs from 'query-string'
function resolve (url) {
const params = qs.stringify({ a: 1, b: 2 }) // a=1&b=2
return url + (url.includes('?') ? '&' : '?') + params
}

看上去问题似乎解决了,很多人也只考虑到这一层,但现在还有这样一种url:

1
http://taobao.com/#/xxx

尤其是单页应用,这个形式的hash路由非常常见。

如果只是简单地拼接到URL尾部:

1
http://taobao.com/#/xxx?a=1&b=2

将这段URL中的search解析,得到的结果是

1
{}

可以看到拼接的参数根本没跑到search里面去。

也就是说,只要URL中出现了#,这之后出现的?就不会被视作search的起始标志,而是hash的一部分。

如果URL再复杂一点,比如:

1
http://taobao.com/?c=3&d=4#/xxx

上面的拼接会变成:

1
http://taobao.com/?c=3&d=4#/xxx&a=1&b=2

不但没有按照预期插入参数,还破坏了原本的hash结构。

实际上,以vue-router为例,它的路由系统中恰好就用到了hash中的?

比如:

1
http://example.com/user/:foo/info?c=3&d=4#/xxx?a=1&b=2

在vue-router里,xxx是路由的path,foo被称作params,ab被称作query,分别可以通过
route.paramsroute.query获取。

cd才是search,需要从location.search中解析。

如果后端接收了这样一段GET请求,hash后面的东西都会被抛弃,只有search可以被接收和解析。

因此,插入参数不能出现在#之后,也就是说,简单地在URL后面拼接字符串是不行的。

第三层: 只处理search

1
2
3
4
5
6
7
import qs from 'query-string'
function resolve (url) {
const params = qs.stringify({ a: 1, b: 2 }) // a=1&b=2
const urlObj = new URL(url)
urlObj.search += urlObj.search.startsWith('?') ? '&' : '?' + params
return urlObj.href
}

这样就可以处理上面的复杂情况了。

使用URL对象是一个讨巧的办法,将url字符串解析为URL对象后,可以只修改它的search属性而不影响其他部分。

axios的源码中,我们可以看到另一种标准实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function buildURL(url, params) {
if (!params) {
return url;
}

// params序列化过程略
var hashmarkIndex = url.indexOf('#');
if (hashmarkIndex !== -1) {
url = url.slice(0, hashmarkIndex);
}

url += (url.indexOf('?') === -1 ? '?' : '&') + params;

return url;
};

思路很简单,就是把序列化后的params插入到URL的末尾,但若存在hash,则插到hash之前,若存在search,则连接符改用&

相关链接:

What is a URL

URL对象

Vue-router 路由对象

阿里正在将淘宝无线开放平台等业务整合为商家应用,类似于支付宝小程序,也就是以小程序的形式进行淘宝天猫店铺的展现、营销。

最近正在开发商家应用小程序,踩了无数坑,原本以为微信小程序已经够坑了,没想到这个几乎照搬微信小程序模式的淘宝小程序更坑。毕竟刚起步,抱怨也没用,将我遇到的问题先记录下来,让后来者可以少花一些时间。

微信小程序遇到开发问题时有一个社区平台可以交流。优点是开发者之间可以互相交流,有一些回帖非常有价值;缺点是提问不一定能得到官方解答,很多帖子可能会石沉大海。

而阿里系这边,反馈问题不能通过社区,只能使用工单系统。优点是有问必答,工作日一般半小时内就可以看到回复,几句话说不清的还可以转钉钉详聊;缺点则是回答者对专业问题几乎一窍不通,只能算是小程序开发者与官方平台开发者的传话人,与所有客服系统一样,只会把平台文档里已经有的东西车轱辘话来回说,除非你用明确的语言表示自己这个问题确实不在文档里。而且一旦客服表示问题复杂邀请你进了钉钉详聊,那这个问题可能很快能解答,也可能再也没有回音。另外,工单系统看上去是10年前开发的样子,贴图、贴代码都极不友好,搜索功能也几乎是废弃状态。

再多吐槽一句,文档系统也非常糟糕,V1和V3文档同时存在并运行,很多文档中的链接都没有更新,你会在逐步的浏览中先后打开v1、v3甚至支付宝小程序的文档,而更复杂的TOP接口文档,可能会让你进一步陷入迷惑,甚至连你想要的功能应该属于哪个分类都难以确定。

言归正传,记录下我开发中遇到的问题。

发起网络请求

我原本已经封装了一套基于my.request的api工具,结果真机一调,发现不能用。

文档没有删干净,仿微信的my.request已经被废弃了。

对于云应用,现在官方强推的唯一用法是cloud.application.httpRequest

https://miniapp.open.taobao.com/docV3.htm?docId=118538&docType=1

父组件不能定义自定义子组件的class

比如

1
<aaa class="test" />

这种写法是无效的,父组件定义到自定义组件上的样式会被丢弃。

微信小程序和VUE中,像这样的定义在最终会与组件最外层内部定义的class合并到一起,但在淘宝小程序里就是不行(官方提供的组件倒是可以)。

我的临时处理方式是:

1
2
3
<view class="test">
<aaa />
</view>

然后样式写:

1
.test > view

如果你用了stylelint之类的校验工具,记得将view这个标签关键字加到白名单。

也许也可以加一个名叫class的props来迂回实现,待验证

上传图片

上传图片的实现折腾了我很久,而这几乎是所有开发者绕不开的问题,我很诧异官方竟然没有及时更新文档来指出。正确的方法在本节内容的最后,前面的试错方法是行不通的。

首先,文档确实存在了这样一个接口:my.uploadFile

所以我的第一反应就是用它来实现图片上传,微信小程序也是这么做的。

实际上my.uploadFile就是一个传递流的HTTP请求,我希望将图片上传到阿里云OSS服务,因此,我需要手写模拟出OSS上传所需的各种参数。

参考了这篇文档来实现。

注意,这是在小程序客户端实现根据密钥生成签名,私钥暴露到了前端,理论上有安全隐患。

拼完参数以后,还要指定上传服务器地址,也就是HTTP请求的URL。

例如:https://my-oss-name.oss-cn-hangzhou.aliyuncs.com

四级域名是阿里云OSS的bucket名称。

折腾完上面这些以后,在本地开发者工具上测试没有问题,但预览和真机调试时发现报错。
错误信息为

1
{errorCode:4,errorMessage: "no premission"}

工单反馈才知道,my.uploadFile限制了域名。只允许以下域名规则:

1
2
3
4
5
6
miniappcloud.taobao.com
media.taobao.com
upload.media.aliyun.com
miniappcloud-common-file.oss-cn-zhangjiakou.aliyuncs.com
\\S\\.alicdn\\.com$
\\S\\.mmstat\\.com$

简单来说,就是my.uploadFile这个接口只有阿里内部服务可以使用,无法被普通开发者调用,应当屏蔽和废弃。

上传图片现在只有一条路可走,就是改用阿里云开发中的云存储

这里问题又来了,文档说,小程序关联云服务,只能在云开发和云应用里二选一

而我们的后台用的就是云应用,难道就不给用云开发里的云存储功能了?

幸好实际使用发现,即使选择了云应用,仍然可以使用云开发的云存储功能。不过,云存储的管理只能在开发者工具中进行。

注意,每次点击开发者工具中的云开发,都会自动在项目下创建server和client目录,并将项目原来的文件包括node_modules全部挪到client下,如果你不希望目录被改变,记得先启用git,没事别点这个按钮

云存储的调用就不详述了,但还有一个坑待验证:

使用云存储上传成功后,拿到的是一个cloud协议前缀的内部地址和一个URL。

文档和客服的反馈都表示,这个URL不是永久有效的,也就是你如果想要访问这个文件,需要先调用云存储的接口获取临时地址。

这个设计带来一个麻烦的问题,不能直接把存储的图片地址赋值给image组件,因为image所需的URL只能异步获取到,你需要自己再封装一个组件用来显示云存储图片。

不过实际使用发现,上传成功后获得的这个URL到目前为止还没有过期,就先这样用着了。

预览和真机调试时,二维码丢失全局参数(2020-05-18似乎已修复)

在编译模式中添加的全局参数,本地调试都能生效,但预览和真机调试时,这些参数都被丢弃了。在编译模式里的配置项只有默认页面是生效的。

询问工单后得知,只能自行添加参数。

首先点击编译成功后得到的二维码图案,获得一串地址,类似这样

1
https://m.duanqu.com?_ariver_appid=xxx&nbsv=0.1.2004151019.52&nbsource=debug&nbsn=DEBUG&channelId=1jOXeERZ54A_2IS7tG_0Co1du_0149_1vA&isRemoteX=true

然后需要将全局参数进行url编码,比如原始参数

1
dev=true&id=123

编码后变成

1
dev%3Dtrue%26id%3D123

作为query参数的值,拼接到url上,变成

1
https://m.duanqu.com?_ariver_appid=xxx&nbsv=0.1.2004151019.52&nbsource=debug&nbsn=DEBUG&channelId=1jOXeERZ54A_2IS7tG_0Co1du_0149_1vA&isRemoteX=true&query=dev%3Dtrue%26id%3D123

最后在浏览器打开这段地址,然后进行扫码。

每次真机调试都要这么来一次,非常麻烦,不知道什么时候才能修正。

PC端基础组件overlay运行不正常

PC端尝试使用overlay组件实现弹窗效果。

要弹出一个居中的overlay,需要这样配置:

1
2
3
4
5
6
7
8
<overlay
visible="{{ visible }}"
onRequestClose="onClose"
align="cc cc"
hasMask
>
弹窗内容
</overlay>

随后发现两个致命问题:

  1. overlay层级无法修改

弹窗内部如果有进行系统级弹窗的操作,比如执行my.chooseImage后,系统弹出的选择相册菜单被盖在了当前弹层下方。
并且文档中没有关于overlay层级调整的相关方法。

  1. overlay中有任意自定义组件,第二次弹出时必定崩溃

比如最简结构

1
2
3
<overlay visible={{visible}}>
<aaa />
</overlay>

当visible第二次被修改为true时,app崩溃,错误提示为

1
worker render components is not sync! can not find id from path: 1-1-1-3-1:/src/components/aaa/index

这个问题客服只说转给开发了,但至今没有反馈,因此overlay无法被实用,只能自己实现一个弹窗组件。

自定义组件的样式是全局生效的

page的样式只在page内生效,这是有共识的,但我没想到的是component下定义的样式竟然会是全局生效的。

不同的自定义组件中出现同名样式时,会互相影响和覆盖。

而商家PC端现在官方强推的是使用扩展组件里的路由系统(顺带一提这路由系统非常简陋,使用又繁琐),
如果使用了路由系统,就意味着所有页面都是component,这时候这种全局scope就会严重影响开发。

临时的解决方式是,手动在组件样式最外层套上当前组件的class(用scss会比较方便)

PC端千牛使用远程调试时无法收到调试信息

按照文档配置:

https://miniapp.open.taobao.com/docV3.htm?docId=118310&docType=1

能正常从千牛中启动预览包,能打开chrome上的devtool标签页,并且关闭当前的预览应用时,能看到chrome弹出连接断开的提示。

但预览应用中执行的各种输出控制台信息,在chrome调试标签页都没有反应,看不到任何变化和输出。

工单反馈说:

目前发现 chrome 80.0.3985.0 及以上版本(也可能是79以上,边界没有定位得很精确),能连接上小程序js环境,甚至能打断点调试代码,但就是看不到console输出,直接在console里输入 console.log,也是不输出的。此问题已知待解决,请尝试用低版本chrome绕过。

我不想装旧版本的chrome,有精力的同学可以试一下。

商家后台千牛PC端应用上传后,预览码无法在千牛客户端打开

商家后台千牛PC端应用上传成功,在版本管理-开发阶段中可以查看到,性能任务已通过,但查看并复制预览码后,在千牛的开发者设置中粘贴预览码并打开时,提示页面不存在。

在本地调试时点击预览后复制二维码并粘贴打开是正常的。

这实际上并不是小程序本身的问题,而是千牛端即使处于登录状态,也可能有什么类似session的东西过期了,需要手动重新登录一下千牛。

hideLoading以后执行showToast无效

文档里写了

在 my.showToast 之前调用 my.hideLoading,toast 被 my.hideLoading 覆盖,将不展示。

如果我将异步请求封装为公共方法,在请求前showLoading,请求后hideLoading,结果就无法在请求完成后显示结果。

工单没有对这个问题做出有效解答,实际测试发现在开发者工具上模拟时不会弹出toast,但真机却可以正常弹出。

分享在path里添加的参数在终端打开时取不到

这算得上是一个大坑。

实际测试发现app.onLoad里取不到,page.onLoad里才有。

通过分享打开小程序时,得从页面onload的query里取参数。

1
2
3
onLoad(query){
console.log(query)
}

通过扫码打开时,得从app的options里取参数。

1
2
3
onLoad(options) {
console.log(options.query)
}

这里有一个很关键的问题,对商家应用来说,启动参数是不可或缺的,比如营销活动的id,没有id,应用就不能进行任何实际操作或展示。

因此,我只能将初始化动作变成一个分几步走的过程:

  1. 创建全局的初始化promise对象
  2. app.onLoad中获取参数,如果没有取到关键字,则跳过
  3. page.onLoad中获取参数,如果还是没有取到关键字,则进入模拟数据展示,使用模拟数据进入下一步

还记得前面二维码不带参数的问题吗?现在提审时不能像微信那样为某个版本添加编译参数,只能把组装好参数的二维码对应的URL贴到审核备注里,而应用审核者经常不看备注直接扫码打开应用,他看到一片空白时,就会拒绝通过。为了增加过审几率,必须制作一个模拟数据展示模式。

  1. 根据参数进行初始化动作,比如判断运行环境,初始化api接口cloud实例,将活动id放到globalData里等等。最后将初始化promise对象置为resolved。
  2. app或者页面的后续动作都要在初始化promise对象完成后进行。关键在于cloud实例的初始化也是异步的,只有在cloud初始化完毕后,才能执行获取数据等异步请求操作。

PC端千牛只能在page.onLoad里接受参数

与前一个问题有微妙的不同。

生成的二维码地址如:

1
https://m.duanqu.com?_ariver_appid=xxx&nbsv=0.1.2004161617.2&nbsource=debug&nbsn=DEBUG&query=dev%3Dtrue

并不能在app的onLoad里拿到query信息。

只能在page的onLoad里拿到整个options对象。

区别在这里,文档描述的正常场景中,onLoad的入参应该是一个query对象。我不确定这种不协调的差异什么时候会被修正,建议将处理语句写成:

1
let query = options.query || options

返回结果只有一个false,会自动变成空对象

后台返回的内容要注意了,比如有个接口是检测用户是否会员或者检测用户剩余游戏次数,不要直接返回一个boolean或者number对象。

因为cloud.application.httpRequest这个接口我估计会用一个if条件来判断后台返回的data。如果后台返回false或者0,前端拿到的最终结果总是一个空对象{},前端也用if判断就会出错。

因此,确保所有接口返回的内容都是一个复杂对象。

跳转到店铺固定页接口天猫无反应

使用my.tb.navigateToTaobaoPage跳转到店铺会员页,在手淘上正常,天猫上无反应,且没有错误提示

工单答复为:

my.tb.xxx 接口预计在月底版本支持天猫客户端

image的相对路径是相对于页面而不是当前组件的

这个问题也很恶心。等于说组件不能有独属于自己的图片存放目录,无法实现解耦。

工单答复也是建议图片统一放到assets目录下,或者放到OSS上。

我反问1耦合性怎么办,2远程请求影响加载速度怎么办。

我认为这明显是个BUG,客服只说已反馈给开发,目前无下文。

分享设置的content自定义淘口令无效

分享的使用文档

内容实际上是从支付宝小程序文档里复制的,里面的描述都还是吱口令

工单答复是,暂不支持,文档会去掉(截止发文时还没去掉)

关注店铺

需要到C端模板下的权限管理中,申请

商家应用商品收藏、店铺关注权限包

注意不是最外面应用管理下的商家应用,那里的权限管理中没有该权限包

关注店铺相关接口为

https://miniapp.open.taobao.com/docV3.htm?docId=913&docType=20

注意这里的id,不是店铺Id,而是店铺所属卖家Id。

需要再申请一个卖家信息查询权限包,由服务端查询并返回给消费者端。

实际使用时,安卓下测试通过。

IOS下执行出错,错误为:

1
{ code:2 , errorMessage: "参数错误:id" }

另外,IOS下的Error对象字段也与安卓不同,不是标准的Error对象。

安卓下返回的错误提示文字字段为message,而IOS下是errorMessage。

后来发现原来是id不能为字符串,必须是number类型。

权益中心插件配置

发放优惠券需要用到权益中心。

权益中心插件,本地模拟器不能运行,只能在PC千牛上调试。

同样的,因为chrome版本问题导致看不到调试信息,目前还未修复。

使用过程中发现无法以组件形式引入插件,折腾半天以后发现,原来是文档不一致。

https://console.open.taobao.com/?spm=a219a.7386653.0.0.143d669aiBAGsb#/market/plugin?tabKey=document&pluginId=3000000002026202

插件中心的这篇文档害人不浅。

1
2
3
4
5
6
"plugins": {
"myPlugin": {
"version": "0.0.1",
"provider": "3000000002026202"
}
}

注意app.json不要跟着示例写,provider上没有双括号。

然后插件文档里说支持组件形式调用,实际上是不行的。使用文档以

https://miniapp.open.taobao.com/docV3.htm?docId=117142&docType=1

这篇为准。

页面配置transparentTitle

设置transparentTitle的值为always时,安卓下表现正常。

但IOS上向上滑动时,会随着滑动在顶部出现白色导航栏,且导航栏上面没有标题文字,也不能用titleBarColor修改文字颜色,只是一个白色长条。

结果因为这个长条,审核无法通过。

需要将该字段删除,默认就是不显示导航栏。

PC端表单组件文档未完善,validate几乎无法使用

文档中validateState的描述中提到:

校验状态,如不设置,则会根据校验规则自动生成

但并无文档或例子说明校验规则在哪里配置。

已弃坑

由于上架应用几乎无人问津,业务已中止,弃坑了╮(╯▽╰)╭

BaseMapper提供的默认查询方法都是单表的,如果需要多表关联查询,同时还要支持分页,一个方案就是自己写Sql。

现在自己写Sql一般用的应该不是传统MyBatis那种写xml的老方法,而是通过在Mapper类里写注解来实现。

还需要解决的就是如何在自定义Sql语句中拼接MyBatisPlus的分页参数和查询条件。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface GameQueryMapper extends BaseMapper<GameQuery> {
String querySql = "SELECT a.*, b.org_id FROM t_game AS a LEFT JOIN t_game_game_org AS b ON b.game_id = a.id ";
String wrapperSql = "SELECT * from ( " + querySql + " ) AS q ${ew.customSqlSegment}";
/**
* 分页查询
*/
@Select(wrapperSql)
Page<GameQuery> page(Page page, @Param("ew") Wrapper queryWrapper);

/**
* 普通查询
*/
@Select(wrapperSql)
List<GameQuery> list(@Param("ew") Wrapper queryWrapper);

/**
* 单独查询
*/
@Select(wrapperSql)
QyyOrgQuery one(@Param("ew") Wrapper queryWrapper);
}

关键在于Sql字符串最后那一句${ew.customSqlSegment},是用来拼接LambdaQueryWrapper等查询条件包裹器对象最终输出的Sql语句的。

前面不能有WHERE,所以我在最外面又包了一层,将纯粹的多表关联查询语句与特殊组装语句区分开,这样不但可以在自定义Sql内部使用WHERE语句,也便于复制和创建新的Mapper。

但这里还要注意一个关键问题。

在Mapper里面自定义Sql注解对应的方法,其返回的Pojo对象,以及Mapper类指定的Pojo对象,他们必须完全一致。

即:extends BaseMapper<T> 中的T需要与Page<T>一致

如果我在public interface GameMapper extends BaseMapper<Game>里写了这样一个方法:

1
Page<GameQuery> page(Page page, @Param("ew") Wrapper queryWrapper);

即使GameQuery这个对应多表关联字段的Pojo继承自Game,也会出现如下错误:

1
evaluating expression 'ew.customSqlSegment'. Cause: org.apache.ibatis.ognl.OgnlException: customSqlSegment [com.baomidou.mybatisplus.core.exceptions.MybatisPlusException: can not find lambda cache for this entity

因此,多表关联分页查询的Mapper需要单独新建,与单表实例分开。

在将测试库的新增表通过Navicat向阿里云的MySql数据库复制后,发现前端页面请求查询失败。

目标数据库的默认排序规则是utf8mb4_0900_ai_ci,已存在的表都使用了这个编码,而来源数据库的排序规则是utf8mb4_unicode_ci,新复制的表都用的是后者。

数据库进行多表关联查询时,如果两张表的字符集或者排序规则不一致,就会报错。

从Navicat里手动修改编码效率很低,若只是改一下表,也用不了多久,但问题是只改表是不行的,表内所有varchar的编码并不会跟着表走。

因此还是需要走批量操作的路子。

批量修改字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SELECT
CONCAT(
'ALTER TABLE `',
TABLE_NAME,
'` MODIFY `',
COLUMN_NAME,
'` ',
DATA_TYPE,
'(',
CHARACTER_MAXIMUM_LENGTH,
') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
( CASE WHEN IS_NULLABLE = 'NO' THEN ' NOT NULL' ELSE '' END ),
';'
)
FROM
information_schema.COLUMNS
WHERE
TABLE_SCHEMA = '数据库名'
AND (
DATA_TYPE = 'varchar'
OR DATA_TYPE = 'char')

批量修改表

1
2
3
4
5
6
SELECT
CONCAT( 'ALTER TABLE ', TABLE_NAME, ' CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;' )
FROM
information_schema.TABLES
WHERE
TABLE_SCHEMA = '数据库名';

将以上SQL语句中的utf8mb4utf8mb4_unicode_ci数据库名分别改成自己需要的值,成功执行后,将执行结果即SQL语句复制出来,再执行这些SQL语句即可。