小程序踩坑小记(一)
最近公司将搁置了一年的将 RN 应用搬到小程序的计划提上议程,由此开始了这段时间的踩坑之旅。考虑到技术栈为 react,因此使用 tarojs 作为基础框架。
mock
在 rn 项目中,我们使用 axios 并搭配 axios-mock-adapter 作为 mock,而 tarojs 提供了 mock 插件,由于仅在 dev 场景下才会使用到 mock,配置如下:
// package.json
"scripts": {
"dev:weapp:mock": "MOCK=1 npm run dev:weapp",
},
新增一个 script, 并且添加环境变量 MOCK=1。然而仅仅是这样的话,如果我们在业务代码中去取 process.env.MOCK,取的值是 undefined。
解决的方法是在 config/dev.js 下注入,配置如下:
{
env: {
NODE_ENV: '"development"',
MOCK: process.env.MOCK,
},
}
此时在业务代码中去取 process.env.MOCK 可以得到 1。这里值得注意,按道理说应该获取到字符”1”,然而实际上却是 number 类型的 1。
在 config/dev.js 下还需添加一个mock 插件的配置
{
plugins:
process.env.MOCK === '1'
? [
[
'@tarojs/plugin-mock',
{}
],
]
: [],
}
至此,当我们运行 npm run dev:weapp:mock时,将会启动一个 mock 服务器。
那么接下来就需要定制一下我们的请求
request
tarojs 提供了 request 来发送请求,但仅仅这还不够用,比如,我们希望本地开发的时候,mock 时使用的是 mock 服务器的接口,非mock 时使用测试服务器的接口,上线的时候使用的是线上的接口,即一个根据不同环境 baseUrl 的功能。
判断 开发/线上环境可以通过 getAccountInfoSync 来获取,判断是否 mock 可以通过 process.env.MOCK 来获取。
然而 request 并不像 axios 那样可以根据配置创建一个 instance 实例,并且提供 baseUrl 这样的配置,所幸 request 可以自定义拦截器 interceptor。
利用这点实现上述需求
import { request, addInterceptor, getAccountInfoSync } from '@tarojs/taro';
const accountInfo = getAccountInfoSync();
const interceptor = function (chain) {
const requestParams = chain.requestParams;
const { url } = requestParams;
const host =
accountInfo.miniProgram.envVersion === 'develop'
? // 这里的 process.env.MOCK 按道理应该是 string,然而实际上 taro 给注入的是 number,因此使用 any 暂时跳过
(process.env.MOCK as any) === 1
? MockServer
: TestServer
: ProServer;
return chain.proceed({ ...requestParams, url: `${host}${url}` });
};
addInterceptor(interceptor);
request 返回的结果 res 中的 data 字段是接口返回给我们的内容,那么我们还希望能直接获取到 res.data。通常这里还是会用 interceptor 来做一层拦截,返回 res.data , 但是这样的话,在 Typescript 中你会发现返回的类型与 request 的返回类型是不一致的。因此为了不影响输出的类型,我们可以对 request 做一层封装
const myRequest = <T = any>(config: request.Option) =>
request<{ code: number; data: T }>(config).then((res) => {
const { code, data } = res.data;
return data;
});
export default myRequest;
到这一步我们的请求就封装好了。
页面背景色
关于页面背景色,小程序有个 app.json 配置项 backgroundColor,如果我们在这里设置的话你会发现基本没用,那是因为这个配置项实际上指的是窗体的背景色,而非 page 的背景色,因此解决方案就是直接在全局样式里给 page 添加样式。
说到这个 backgroundColor,如果我们配置页面的 enablePullDownRefresh 下拉刷新,你会发现下拉刷新似乎没有动画,这是因为backgroundColor 默认色值是白色,而这个和动画的颜色是一样的。
图片静态资源
小程序要求打包后体积不超过 2m,因此图片等静态资源需要上传到 cdn (上传方法不赘述,根据自己的 cdn 工具编写脚本代码即可)。webpack 的 output 提供了 publicPath 属性来配置。
因此,在生产环境配置如下
// config/prod.js
module.exports = {
env: {
NODE_ENV: '"production"',
},
defineConstants: {},
mini: {
output: {
publicPath: 'https://your.cdn.com/',
},
}
};
那么生产环境打包就会将静态资源引用地址变成诸如 “https://your.cdn.com/your.png" 的形式。
这是在 js 代码中直接引用图片的处理,还有一个容易忽略掉的地方是 css 文件中也会用到 background-image,这时候需要配置 imageUrlLoaderOption 属性
// config/prod.js
module.exports = {
env: {
NODE_ENV: '"production"',
},
defineConstants: {},
mini: {
// ...
imageUrlLoaderOption: {
limit: 0, // 单位为 byte 字节
fallback: {
loader: 'file-loader',
options: {
outputPath: 'assets/images',
name: '[name].[ext]',
publicPath: 'https://your-cdn/assets/images/',
},
},
},
}
};
如此这些 css 中的图片也会转成 cdn 地址。
假设我们的静态资源全部都位于 src/assets 里,通过上述配置我们的静态资源还是会被构建到 dist/assets 目录下。此时有几个方案:
在打包脚本里直接删除
rm -rf src/assets在
project.config.json中配置packOptions{ //... "packOptions": { "ignore": [{ "type": "folder", "value": "assets" }] } }根据小程序的文档,这么配置将会在 打包和预览时根据规则忽略上传的文件。然而根据实测,在微信开发者工具编译时也会导致读取不到文件,这里是官方社区的回答 https://developers.weixin.qq.com/community/develop/doc/0002a2a6fe4e38a8c668c95d25b400?highLine=packOptions
目前还不支持这种需求,可以考虑在上传/预览时,才启用 packOptions 字段(例如debug时把这个字段改成 packOptions2,上传时改成 packOptions)
怎么说呢,就挺无语的。。因此该方案不可取
通常我们不会人工打包,小程序页提供了 CI 接口来实现 CI / CD,而 CI 里提供了配置来忽略文件。下面我会在 CI 发布中详细解释。
git flow
说 CI 之前,得先设计好 git flow。小程序与 web 不同之处在于只能有一个体验版及一个线上版,而 web 我们通常是可以有测试,预发,正式等环境。而当前项目实际上有涉及直播功能,其中后台创建直播间时,服务端却无法区分环境,为此额外申请了一个小程序作为测试环境。为了方便描述,将原小程序称为 A,测试小程序称为 B。
目前设计的 git flow 如下:
- Release 分支对应测试环境,代码将会发布到 B,simulation 分支对应预发,代码发布到 A,master 分支仍然为线上稳定版本。
- 上线时将 simulation 提升版本号后合并回 master 分支,此时触发 CI 发布到 A 上。
- 经测试,同样开发者上传到小程序上,是会直接覆盖更新的,就是说如果 B 上面已经有了一个我已经上传了一个体验版,那么如果再次上传更新的代码,就会将之前的体验版直接覆盖掉。利用这一点,代码上到 release 时时可以不修改版本号的。如此测试同学就无需每次扫码更新测试环境的版本。那么从发布代码脚本层面上来说就是 release / simulation 分支上的代码构建时完全可以将版本号定死,而不用从 package.json 中去获取,那么需要维护版本号的地方只有 master 分支。
- master 版本号主要是为了方便回滚, 这里需要写好 ChangeLog, 打 tag。
- master 分支还需要注意的一点是 envVersion 此前我们根据这个值来判断环境,然而小程序在审核时这个值是不规律的,因此在master 分支打包时需要注意请求的 host 不能请求到测试环境。
- 版本号管理:x.y.z , 热修复则提升 z,需求提升 y,breaking change 提升 x
CI 发布
这里讲一下 ci 发布的部分
const ci = require('miniprogram-ci');
// ...
const project = new ci.Project({
appid: 'your_app_id',
type: 'miniProgram',
projectPath: DIST,
privateKeyPath,
ignores: ['node_modules/**/*', 'assets/**/*'],
});
await ci.upload({
project,
version,
desc: `上传 ${version}`,
setting: {
es6: false,
},
});
主要还是利用微信提供的 ci ,上面的 ignore 就是我们忽略静态文件上传的配置
预览
小程序的真机预览会将代码打包后传输,同样这里预览也是要求体积不超过 2m。
可以看出小程序的预览本意是尽量模拟打包后的环境,那我们尽量不要在 dev 模式下预览,因为 dev 模式下还会打出 source map,也没有做压缩,很有可能导致代码包超过 2m 或者预览结果和最终打包结果不一致,因此我们用
taro build --type weapp --watch
这个模式进行预览,那么静态资源呢?静态资源此时还没有上传到 cdn,因此我的思路是额外注入 PREVIEW=1,即表示当前是预览模式
同时启动一个本地服务,我这里简单使用 koa 来搭建这个服务用于提供静态资源
// server.js
const Koa = require('koa');
const serve = require('koa-static');
const path = require('path');
const chalk = require('chalk');
const DIST = path.resolve(__dirname, '../src');
const server = async () => {
const app = new Koa();
app.use(serve(DIST));
app.listen(3000);
console.log(chalk.green('本地静态资源服务启动,端口号:3000'));
};
try {
server();
} catch (error) {
console.log(chalk.red(error));
}
在这个模式下 publicPath 为改为本地服务的 ip及端口即可,详细配置上面已经讲过类似的,这里就不展开说了。
可以看到服务器的静态资源目录是 src,为啥不是 dist 呢?其实 dist 也可以,本地预览时,静态资源还是会被构建进 dist 的。但这样小程序体积就容易超过 2m,就又回到了上面的提到过的问题。此时已经没了 ci 的帮助,因而我这里的只能去删除 dist/assets ,那么就只能从 src 去提供了。小程序实际上提供了几个 hook,分别是编译前,预览前,上传前
// project.config.json
{
// ...
"scripts": {
"beforeCompile": "",
"beforePreview": "rm -rf dist/assets",
"beforeUpload": ""
},
}
一开始,我是在预览前将 dist/assets 删掉,后来发现这么做有几个问题:
- 需要开启微信开发者工具的自定义处理命令
- 用 windows 的同学不支持 rm
后面发现 Taro 提供了 plugin,暴露了构建时的一些钩子,如 onBuildFinish,通过自定义插件,在构建结束后删除 assets 即可,这里用了 fs-extra 提供的 removeSync 来删除,具体实现挺简单的,这里不赘述。
状态管理
给后面内容做铺垫,这里提前讲一下当前项目的状态管理以及选型的理由,以供参考。
目前 react 比较主流的一些状态管理有:纯 Redux,基于 Redux 的诸如 saga / dva,或者是基于观察者的 mobx, 有基于 hooks 简单封装的 unstate,同时官方也在做一个 recoil,也有一些偏门方案如 rxjs 可以说是五花八门了。这个我倒是比较欣赏 vue 的简单粗暴(别整这些幺蛾子,就用 vuex)
redux
redux 之流的模板虽然是为了更好约束开发者,但是写起来实属繁琐,因此暂不考虑。
mobx
mobx 之前有做过一些尝试,mobx 给我的感觉就很像 vuex。 react 在推 fp,而 mobx 更接近 oop。
主要的问题在于和 react 一直推崇的 immutable 思路是完全不同的,mobx 更推荐的是 mutable,在一些时候 immutable 的写法反而会使数据失去响应性。
mobx 使用 Class 定义 store,那么就需要注意 this 指向等问题(例如 action 中的 this 指向,需要用 action.bound 等 api 来 hack)
更不用说还有一些常见陷阱,如 https://cn.mobx.js.org/best/react.html#%E5%B8%B8%E8%A7%81%E9%99%B7%E9%98%B1-consolelog
当然我并不是说 oop 不如 fp,只是种种特性让我在 react 中使用 mobx 的时候感觉自己相当精分,以至于不能确定运行的结果是不是真的会如自己预期。
hooks 类型的状态管理
这种方案一般是借助 useContext 来实现的,这种方案我并没有使用过,不过很容易想到的就是性能问题,关于性能问题可以参考 https://github.com/ascoders/weekly/blob/v2/146.%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20Hooks%20%E6%95%B0%E6%8D%AE%E6%B5%81%E3%80%8B.md
如果只是做简单的状态管理应该是个轻量的方案,但是目前的项目而言是不太合适的。
recoil
这个是官方在做的一个状态管理,体验的过程是,使用起来相当方便,每个状态相当于一个原子,在需要使用的时候就
const textState = atom({
key: 'textState', // unique ID (with respect to other atoms/selectors)
default: '', // default value (aka initial value)
});
function TextInput() {
const [text, setText] = useRecoilState(textState);
const onChange = (event) => {
setText(event.target.value);
};
return (
<div>
<input type="text" value={text} onChange={onChange} />
<br />
Echo: {text}
</div>
);
}
和 useState 几乎一样,同时还提供了selector 用于缓存提高性能,于此同时还支持 React.Suspense 等
实际上我挺倾向与这个方案的,但是这个库还处于实验阶段,并不推荐在生产环境中使用。
当然上述种种仅仅是我个人的一个简单实用体验,每个人习惯的编程思路不太一样,仅供参考。
那么我们最终使用的状态管理方案是 jotai + zustand。这两个可能对大家比较陌生。jotai 的 api 与 recoil 极其相似,每个状态都是一个 atom
import { atom } from 'jotai'
const countAtom = atom(0)
import { useAtom } from 'jotai'
function Counter() {
const [count, setCount] = useAtom(countAtom)
return (
<h1>
{count}
<button onClick={() => setCount(c => c + 1)}>one up</button>
)
}
相比于 recoil,官方提出这么些优点:
- 核心 api 更少
- 模版代码更少(不需要提供 key)
- 代码体积更小
- 可以很方便接入其他第三方库拓展能力,如 zustand 等
- 内置持久化方案
- 文档更友好
见 https://github.com/pmndrs/jotai/issues/420
关于第 4 点,小程序中加入了 zustand 这个库,主要是因为 jotai 获取或者修改 atom 是通过 hooks,那么如果我们需要在非组件的代码里取值/修改值就需要借助 zustand。
import { useAtom } from 'jotai'
import { atomWithStore } from 'jotai/zustand'
import create from 'zustand/vanilla'
const store = create(() => ({ count: 0 }))
const stateAtom = atomWithStore(store)
store.getState() // { count: 0 }
Zustand 还提供了 subscribe 等非常实用的 api。
关于第 6 点,在小程序中,如果想把状态同步到 storage,我是借助了 zustand
import { persist, StateStorage } from 'zustand/middleware';
const storage: StateStorage = {
getItem: (name: string) => Taro.getStorageSync(name),
setItem: (name: string, value: string) => Taro.setStorageSync(name, value),
};
export const myStore = create<MyStore>(
persist(
(set, get) => ({
data: [],
}),
{
name: 'myStore',
getStorage: () => storage,
},
),
);
export const myStoreAtom = atomWithStore<MyStore>(myStore);
而实际上 atom 本身也是支持持久化的 https://docs.pmnd.rs/jotai/guides/persistence 这里就不展开了
未完待续