小程序踩坑小记(二)

上一篇中主要讲的是工程架构方面的,这篇主要讲一些细节实现

使用原生能力

使用原生 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 很快就暴露了一些问题

  1. 参数需要自行拼接,即需要拼接成 xxx?x=x 的形式,写起来不太友好
  2. 分包的时候由于文件路径变更,所有涉及到的路由都要手动改,这里很容易漏掉或者写错
  3. 不够直观,且无法复用

基于这些问题,我对路由进行了简单的封装

// 给各个页面提供别名
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 又做了什么呢?这里先按下不表,我们需要考虑几个问题

  1. 用户登录态失效了该怎么办,上述代码并没有检测登录态是否失效的逻辑
  2. 进入页面时登录态有效,然而请求接口时用户登录态失效了怎么处理

对的,解决上述两个问题的方法,请求的时候如果服务端返回 401 ,那么我们有两种选择:

  1. 在页面上盖一层提示异常的页面,让用户点击重新登录
  2. 静默执行 login,重新获取用户信息即可

第一种方案看似挺好的,实际上实现起来并不简单,将会涉及到页面布局,数据通信等等。那么如果采用第二种方案要在接口登录态失效的时候 login,需要考虑哪些问题?

  1. 并发: 如果同时有两个接口调用 login,那会不会同时存在两个请求登录的接口,有没有办法优化?
  2. 重试:登录成功之后,之前的失效的请求是否还需要重新发一次呢?
  3. 重试次数:如果登录接口异常,返回的用户信息就是不正确的,那么很可能导致一直重试死循环,那么我们需要限制最多尝试登录的次数。

并发就涉及到 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} />

未完待续