小程序踩坑小记(二)
上一篇中主要讲的是工程架构方面的,这篇主要讲一些细节实现
使用原生能力
使用原生 api,如 wx.xxx,taro 将 wx 暴露的 api 集成到 @tarojs/taro 中了
import Taro from '@tarojs/taro'
Taro.xxx
使用原生组件 taro 的文档讲的就相当模糊。这里我结合自己的场景,一个是用到直播插件, app 配置
// src/app.config.ts
{
// ...
plugins: {
'live-player-plugin': {
version: '1.3.0',
provider: 'wx2b03c6e691cd7370',
},
},
}
其中 wx2b03c6e691cd7370 是微信规定的直播插件,需要写死的
使用时:
<Navigator url={`plugin-private://wx2b03c6e691cd7370/pages/live-player-plugin`}>
进入直播
</Navigator>
使用原生组件需要在页面的配置文件中添加 usingComponents
{
// ...
usingComponents: {
'mp-tabs':
'../../components/@miniprogram-component-plus/tabs/miniprogram_dist',
'mp-sticky':
'../../components/@miniprogram-component-plus/sticky/miniprogram_dist',
},
}
看到这里引用路径是 ../../components/xxx这个路径是怎么来的呢?
原来 taro 不会将没有使用到的 node_module 下的文件拷贝到 dist,那么我们需要就自己去配置拷贝项
// config/index.js
{
// ...
copy: {
patterns: [
{
from: 'node_modules/@miniprogram-component-plus',
to: 'dist/components/@miniprogram-component-plus',
},
],
},
}
这里我们约定 dist/components 为原生组件放置的目录,../../components/xxx 即相对这个目录来的。
使用时
// index.tsx
// ...
<mp-sticky></mp-sticky>
// global.d.ts
// 定义一下 type,如果有用 typescript 的话
declare namespace JSX {
interface IntrinsicElements {
'mp-sticky': any;
}
}
mini-css-extract-plugin 编译警告
mini-css-extract-plugin 需要按引用顺序去生成 css 文件,如果多个文件引入顺序不一致就会警告
chunk common [mini-css-extract-plugin]
Conflicting order between:
...
详见 https://github.com/NervJS/taro/issues/7160
解决方案:我这里引入顺序没有太大的影响,因此选择忽略顺序
// config/index.js
{
// ...
mini: {
miniCssExtractPluginOption: {
ignoreOrder: true,
},
},
}
UUID
前端生成随机 id,通常是通过 Math.random 实现的,例如组内一个小伙伴的实现:
function uuid(len = 8) {
let str = ''
for(; str.length < len; str += Math.random().toString(36).substr(2)) {}
return str.substr(0, len)
}
然而上面实际是伪随机,web 端可以利用 crypto 实现,组内另一个小伙伴的实现
/**
* 用来随机生成文件名的字符串集合,需要达到256位,但超过256位之后会抛弃
*/
const randomSeed = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.repeat(5);
/**
* 使用浏览器的crypto模块,简单地生成随机字符串
*/
export const createRandomString = (len = 16) => {
return Array.from(crypto.getRandomValues(new Uint8Array(len)))
.map((v) => randomSeed[v])
.join('');
};
然而小程序自己提供的 getRandomValues 对微信基础库版本要求较高
const createRandomString = async (length = 16) => {
const res = await wx.getRandomValues({ length });
return Taro.arrayBufferToBase64(res.randomValues);
};
偶然看到 taro-ui 的实现,记录下来:
function uuid(len = 8, radix = 16): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('')
const value: string[] = []
let i = 0
radix = radix || chars.length
if (len) {
// Compact form
for (i = 0; i < len; i++) value[i] = chars[0 | (Math.random() * radix)]
} else {
// rfc4122, version 4 form
let r
// rfc4122 requires these characters
/* eslint-disable-next-line */
value[8] = value[13] = value[18] = value[23] = '-'
value[14] = '4'
// Fill in random data. At i==19 set the high bits of clock sequence as
// per rfc4122, sec. 4.1.5
for (i = 0; i < 36; i++) {
if (!value[i]) {
r = 0 | (Math.random() * 16)
value[i] = chars[i === 19 ? (r & 0x3) | 0x8 : r]
}
}
}
return value.join('')
}
分包
在加入七鱼插件后,尝试上传代码时,小程序提示主包体积过大。分析发现,小程序对于官方提供的插件或者拓展组件,如 直播 / sticky 等组件是不计入体积的,而对于第三方的插件是需要计入体积的。那么怎么解决呢?
已知七鱼插件约1.3 m, 而微信允许一个包最多 2m,显然七鱼的代码不能和业务代码放在同一个包里。而微信也提供了分包的办法。配置起来倒是简单。在 app.config.json 中可以添加 subpackages 字段,将需要分包出去的页面按 root 分别打包。
第二个问题是,七鱼只是提供了跳转插件页的 url plugin://qiyukefu/chat, 可是这个 url 确不能作为分包的 page。因此我单独加了一个页面作为中转到七鱼的入口,即进入这个中转页面后就会执行
redirectTo({
url: 'plugin://qiyukefu/chat',
});
然而,小程序这里还有个坑,如果当前这个插件加载速度比较慢的话,就会导致中转页面先关掉,然后再跳转到七鱼,表现成闪烁一下。这里没什么好的办法,在当前页面延时 1s 后跳转。
同时对这个中转页所在包通过配置 preloadRule 做了一个预加载,优化体验。
路由设计
微信提供了路由跳转的 api,一开始是直接使用的
Taro.navigateTo({
url: 'xxxx'
})
但是直接使用这个 api 很快就暴露了一些问题
- 参数需要自行拼接,即需要拼接成
xxx?x=x的形式,写起来不太友好 - 分包的时候由于文件路径变更,所有涉及到的路由都要手动改,这里很容易漏掉或者写错
- 不够直观,且无法复用
基于这些问题,我对路由进行了简单的封装
// 给各个页面提供别名
export enum RoutesMap {
首页 = '/pages/home/index',
// ...
}
export class Router {
// 记录当前 url
currentUrl;
constructor() {
wx.onAppRoute((res: AppRouteRes) => {
// 当路由变化时,发布一个 ON_ROUTE_CHANGE 的事件
eventCenter.trigger(ON_ROUTE_CHANGE, {
from: this.currentUrl,
to: this.getUrlFromRouteRes(res),
openType: res.openType,
});
this.currentUrl = this.getUrlFromRouteRes(res);
});
}
// 根据 wx.onAppRoute 的返回值拼成完整 url
getUrlFromRouteRes(res: AppRouteRes) {
return '/' + res.path + qs.stringify(res.query || {});
}
/** 获取当前所在页面 */
getCurrentPage() {
const pages = Taro.getCurrentPages();
const currentPage = pages[pages.length - 1];
return currentPage;
}
/**
* 封装跳转,支持 NAVIGATE | REDIRECT | SWITCH_TAB
* @example
* ```tsx
* router.go({ path: RoutesMap.商品详情, data: { id: 1 } })
* ```
*/
go({ path, openType = OpenType.NAVIGATE, data = {} }: GoOption) {
const querystring = stringify(data);
const url = `${path}${querystring}`;
switch (openType) {
case OpenType.NAVIGATE:
return navigateTo({
url,
});
case OpenType.REDIRECT:
return redirectTo({
url,
});
case OpenType.SWITCH_TAB:
return switchTab({
url,
});
default:
break;
}
}
/**
* 监听路由变化
*/
onRouteChange(listener: RouteEventListener) {
eventCenter.on('ON_ROUTE_CHANGE', listener);
return this;
}
/**
* 解绑路由监听
*/
offRouteChange(listener: RouteEventListener) {
eventCenter.off('ON_ROUTE_CHANGE', listener);
return this;
}
}
这里的 wx.onAppRoute 是微信内部使用的 api,没有文档,不过确实可以使用。一开始我是想通过所有跳转的地方都使用自己封装的 Router 来跳转实现记录路由,但是这样其实有一个最主要的问题:无法监听(物理)返回。因此不得已使用这个 wx.onAppRoute。
通过上述简单封装,就实现了路由监听,通过别名跳转等,使用方法如下:
const router = new Router()
router.go({ path: RoutesMap.商品详情, data: { id: 1 } })
router.onRouteChange(() => {
//...
})
登录
微信小程序 sdk 最新有个更新是提供了一个新的api wx.getUserProfile 用于替换 wx.getUserInfo,开发者每次通过该接口获取用户个人信息均需用户确认,见 https://developers.weixin.qq.com/community/develop/doc/000cacfa20ce88df04cb468bc52801?highLine=login
本项目中暂时只需要获取到用户的唯一标识,因此需要用到的 api 是 wx.login。
当我们进入到小程序页面的时候,分两种情况,一种是当前页面需要登录态,一种是无需登录态。进入到需要登录态的页面,如果当前 storage 中没有用户信息,那么可以判断为没有登录态,此时展示 loading 同时调用 wx.login 后,将会得到用户的 union_id,使用这个 union_id 去跟服务端兑换用户信息即可。
那么这里的逻辑是如何封装的呢,代码如下
/**
* 登录 hoc,包裹的页面未登录时会自行进行登录
*/
export default function NeedLogin({ children }: IProps) {
const [userInfo] = useAtom(userInfoAtom);
const childrenVisible = !userInfo;
if (childrenVisible) {
// 如果不是 mock 且无用户信息时,展示 loading 并开始登录流程
return <LoginView />;
}
return children;
}
/** 提供 hoc
* @example
* ```tsx
* export default withLogin(Home)
* ````
*/
export const withLogin = (WrappedPage: ComponentType<any>) => () =>
(
<NeedLogin>
<WrappedPage />
</NeedLogin>
);
为了不影响到原先页面的布局结构,这里提供了一个 withLogin 高阶组件,使用时 withLogin(Home) 即可
可以看到上述代码中,如通过 userInfoAtom 来获取用户信息,实际上这个 userInfo 就是一个持久化到 storage 的状态。如果没有获取到用户信息,将会展示 LoginView 这个组件,那么这个组件做了什么呢?
const LoginView = () => {
useEffect(() => {
const init = async () => {
try {
const currentPage = Taro.getCurrentInstance().page;
await login();
Taro.nextTick(() => {
if (currentPage && currentPage.onShow) {
currentPage.onShow();
}
});
} catch (error) {
Taro.showToast({
icon: 'none',
title: error.message || '登录失败',
});
}
};
init();
}, []);
return (
<View>
加载中...
</View>
);
};
可以看到其实很简单,就是加载的时候执行 login。
这里有个注意点,我们监听被包裹的页面的 onShow 事件,但是 onShow 实际上是页面的生命周期,即未登录时,被包裹页面的组件的 onShow 不会在登录完成后执行。因此做了一个 hack,即登录完成后调用当前页面的 onShow。
回到 login 又做了什么呢?这里先按下不表,我们需要考虑几个问题
- 用户登录态失效了该怎么办,上述代码并没有检测登录态是否失效的逻辑
- 进入页面时登录态有效,然而请求接口时用户登录态失效了怎么处理
对的,解决上述两个问题的方法,请求的时候如果服务端返回 401 ,那么我们有两种选择:
- 在页面上盖一层提示异常的页面,让用户点击重新登录
- 静默执行 login,重新获取用户信息即可
第一种方案看似挺好的,实际上实现起来并不简单,将会涉及到页面布局,数据通信等等。那么如果采用第二种方案要在接口登录态失效的时候 login,需要考虑哪些问题?
- 并发: 如果同时有两个接口调用 login,那会不会同时存在两个请求登录的接口,有没有办法优化?
- 重试:登录成功之后,之前的失效的请求是否还需要重新发一次呢?
- 重试次数:如果登录接口异常,返回的用户信息就是不正确的,那么很可能导致一直重试死循环,那么我们需要限制最多尝试登录的次数。
并发就涉及到 login 的实现了,这里很容易想到就是单例模式,实际上在前端借助 esm 是很容易实现一个单例的
import Taro from '@tarojs/taro';
/** 保持一个 promise 防止并行 */
let loginPromise;
/** 登录 */
export const login = () => {
if (!loginPromise) {
loginPromise = new Promise<UserInfo>((resolve, reject) => {
Taro.login({
async success(res) {
if (res.code) {
try {
// 请求服务端用户信息
let userRes = await getUserLogin({
code: res.code,
});
userStore.setState({ userInfo: userRes });
resolve(userRes);
} catch (error) {
// 清除掉 storage 中的 user 信息
userStore.setState({ userInfo: undefined });
reject(error);
}
} else {
reject({ code: -1, message: '登录失败' });
}
loginPromise = null;
},
fail: (res) => {
reject({ code: -1, message: '登录失败' });
loginPromise = null;
},
});
});
}
return loginPromise;
};
在上述代码中,如果已经存在了 loginPromise,就不再创建新的 promise
第二三点则是需要封装在 request 里,这里就需要对 request 进行一个小改造了
let retryTimes = 0;
async function myRequest(config) {
// 登录失效一共可重试 3 次
let MAX_RETRY_TIMES = 3;
try {
const res = await request(config);
const { code, data } = res.data;
if (res.statusCode === 401 && retryTimes < MAX_RETRY_TIMES) {
// 重试计数器加 1
retryTimes = retryTimes + 1;
// 清空登录
userStore.setState({ userInfo: undefined });
// 过期或者登录态失效
await login();
// 重新请求一次
const res2 = await youpinRequest<T>(config);
return res2;
}
return data;
} catch (error) {
throw error;
}
}
这里整个应用存在的周期允许用户重试 MAX_RETRY_TIMES 次数
一像素边框
写过 h5 的同学可能经常遇到一像素问题,即1px 在移动端实际上对应的是 1 * pixelRatio 的物理像素,导致整个边框显得格外粗,UI 同学就会过来找你麻烦。解决方案也是五花八门,比较通用的方案是通过缩放带边框的伪元素来解决。
.hairline-border {
position: relative;
border: none;
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
transform-origin: top left;
box-sizing: border-box;
pointer-events: none;
width: 200%;
height: 200%;
transform: scale(0.5);
// prettier-ignore
border-width: 1PX;
border-style: solid;
border-color: black;
}
}
之后在需要的组件上添加这个 hairline-border 样式就可以了。然而在每个组件上添加样式名实属有些麻烦,特别是与 css module 在一起使用时
<View className={classnames([styles.myStyles, "hairline-border"])} />
差不多是上述代码这样的,如果组件有圆角则更为麻烦,需要在 myStyles 里写
.myStyles {
&::after {
// 如果需要的圆角为 4,则要写成 4 * 2 = 8
border-radius: 8px;
}
}
还比如我希望仅底部有 border,要怎么处理呢?
这里可以借助 less 提供的函数能力进行一个简单的封装
/**
* 1 像素边框
* @example
* ```
* @import '@/hairline-border.less';
* // 红色的一像素边框
* .hairline-border(red)
* // 圆角为 4px 的一像素红色边框
* .hairline-border(red, 4px)
* // 圆角为 4px 的一像素红色顶部边框
* .hairline-border(red, 4px, top)
* // 不带圆角的一像素红色顶部边框
* .hairline-border(red, 0, top)
* ```
*/
.hairline-border(@color) {
position: relative;
border: none;
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
transform-origin: top left;
box-sizing: border-box;
pointer-events: none;
width: 200%;
height: 200%;
transform: scale(0.5);
// prettier-ignore
border-width: 1PX;
border-style: solid;
border-color: @color;
}
}
.hairline-border(@color, @radius) {
.hairline-border(@color);
&::after {
border-radius: @radius * 2;
}
}
.hairline-border(@color, @radius, @position) {
.hairline-border(@color, @radius);
&::after {
border-width: 0px;
each(@position, {
// prettier-ignore
border-@{value}-width: 1PX;
});
}
}
@media only screen and (-webkit-min-device-pixel-ratio: 3) {
.hairline-border(@color) {
position: relative;
border: none;
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
transform-origin: top left;
box-sizing: border-box;
pointer-events: none;
width: 300%;
height: 300%;
transform: scale(0.33);
// prettier-ignore
border-width: 1PX;
border-style: solid;
border-color: @color;
}
}
.hairline-border(@color, @radius) {
.hairline-border(@color);
&::after {
border-radius: @radius * 3;
}
}
.hairline-border(@color, @radius, @position) {
.hairline-border(@color, @radius);
&::after {
border-width: 0px;
each(@position, {
// prettier-ignore
border-@{value}-width: 1PX;
});
}
}
}
那么使用的时候
.myStyle {
.hairline-border(red, 0, top)
}
是不是就很方便了
<View className={styles.myStyles} />
未完待续