黑客说:如何做到 4 天上线一个小程序?
自 6 月 6 号上线 “黑客说” 网页版(hackertalk.net)以来吸引了很多用户,为了进一步完善终端体验,我们决定复用已有的技术栈,实现微信端小程序,前后开发仅花了4天,本文主要从技术的角度讨论我们如何快速上线小程序。
黑客说是什么 ?
这是我们专门为程序员群体定制的交流平台,有及时技术资讯、高质量技术问答、实用编程经验分享,还有程序员的日常生活。接近 500 个编程相关垂直话题。
一个高度定制的 Markdown 编辑器:所见即所得,再也不用分屏预览了~
感兴趣的小伙伴可以戳下面链接直接体验小程序版本 👇👇
网页端技术栈
为了代码更好地复用和维护,我们在 Vue 和 React 中选择了 React,网页端主要技术栈如下:
react + typescript + redux + immer + redux-saga + axios + tailwindcss + fakerjs
typescript 项目必备,极大提高代码正确性和可维护性 immer 替代了传统的 immutablejs 方案,在 reducer 中实现类似 vue 的直接数值操作(简洁性),同时保持 immutable 数据流的优点(可维护性) saga 保持了API接口调用的简洁性、可调试性 axios 封装了 http 请求,可以通过自定义 adapter 适应不同终端运行环境 tailwindcss 通过原子化的 css 大大降低了样式文件体积,加快网页加载速度,也很大程度降低了小程序包体积(2MB 限制),更多的代码空间可以用于 UI 界面和 JS 逻辑 fakerjs 用于模拟数据,在开发环境中注入数据到 redux,方便调试
小程序端技术栈
小程序端技术栈和网页端高度重合(这也是我们能够快速上线应用的原因),其中最大的变化是由 react 变为 react + taro。
Taro 是一个开放式跨端跨框架解决方案,支持使用 React/Vue/Nerv 等框架来开发 微信 / 京东 / 百度 / 支付宝 / 字节跳动 / QQ 小程序 / H5 / RN 等应用
小程序端开发可谓混乱至极,原生代码难以组织、难以维护,通常都需要一些框架进行封装,Taro 是我们在使用了几个不同方案后决定采纳的,和 react 高度重合,可以直接使用 hook,极大提高代码复用的可能性(这是以前积累的经验基础)。
APP 端技术栈
目前黑客说还没有上线相关 APP,技术栈复用可以直接将 react 换为 react-native。
代码文件组织
组织良好的代码是高度复用的关键,我们采用 components + containers 的代码分割方式,严格规范代码组织方式:
UI 界面相关组件只能放在 components 文件夹,无状态,不能耦合任何状态管理库相关代码 数据注入的容器组件只能放于 containers 文件夹,不能包含任何 UI 相关代码,比如 div
模块化、原子化:代码分层设计,实现组件高度复用,保持应用一致性
文件夹布局如下:
├── assets 固定资源文件:图片、文字、svg 等
├── components 纯 UI 组件
├── constants 全局常量
├── containers 纯容器组件
├── hooks 自定义 hooks
├── layout 布局相关 UI 逻辑
├── locales 国际化相关
├── pages 整页逻辑
├── services API 接口代码
├── store 状态管理代码
├── styles 样式代码
├── types ts 类型声明
└── utils 公共工具类
Store 状态管理
├── actions
├── reducers
├── sagas
├── selectors
└── types
saga 调用 API 代码组织如下:调用调试非常方便
function* getPostById(action: ReduxAction): any {
try {
const res = yield call(postApi.getPostById, action.payload);
yield put({ type: T.GET_POST_SUCCESS, payload: res.data.data });
action.resolve?.();
} catch (e) {
action.reject?.();
}
}
其中的 postApi 来自 services 文件夹:
export function getPostById(id: string) {
return axios.get<R<Post>>(`/v1/posts/by_id/${id}`);
}
小程序端特殊适配
Cookie
由于小程序端无法支持 http cookie,无法像浏览器一样使用 cookie 机制保证安全性和维护用户登录状态,我们需要手动模拟一个 cookie 机制,这里我们推荐使用京东开源的一个方案:京东购物小程序cookie方案实践,可以实现 cookie 过期、多 cookie 功能。其原理使用了 localstorage 替代 cookie。
Http Request
小程序端只能使用 wx.request
进行 http 请求,如果大量 API 直接使用这个接口编写,代码将难以维护和复用,我们使用 axios 的 adapter 模式封装 wx.request
,请求结果和 error 都按 axios 数据格式进行加工。这样我们就能够直接在小程序端使用 axios 了。
转换请求参数:
function toQueryStr(obj: any) {
if (!obj) return '';
const arr: string[] = [];
for (const p in obj) {
if (obj.hasOwnProperty(p)) {
arr.push(p + '=' + encodeURIComponent(obj[p]));
}
}
return '?' + arr.join('&');
}
axios 适配器模式(CookieUtil 代码参考上文京东的例子)
axios.defaults.adapter = function(config: AxiosRequestConfig) {
// 请求字段拼接
let url = 'https://api.example.com' + config.url;
if (config.params) {
url += toQueryStr(config.params);
}
// 常规请求封装
return new Promise((resolve: (r: AxiosResponse) => void, reject: (e: AxiosError) => void) => {
wx.request({
url: url,
method: config.method,
data: config.data,
header: {
'Cookie': CookieUtil.getCookiesStr(),
'X-XSRF-TOKEN': CookieUtil.getCookie('XSRF-TOKEN')
},
success: (res) => {
const setCookieStr = res.header['Set-Cookie'] || res.header['set-cookie'];
CookieUtil.setCookieFromHeader(setCookieStr);
const axiosRes: AxiosResponse = {
data: res.data,
status: res.statusCode,
statusText: StatusText[res.statusCode] as string,
headers: res.header,
config
};
if (res.statusCode < 400) {
resolve(axiosRes);
} else {
const axiosErr: AxiosError = {
name: '',
message: '',
config,
response: axiosRes,
isAxiosError: true,
toJSON: () => res
};
reject(axiosErr);
}
},
fail: (e: any) => {
const axiosErr: AxiosError = {
name: '',
message: '',
config,
isAxiosError: false,
toJSON: () => e
};
reject(axiosErr);
}
});
});
};
axios 适配完成后原先 API 相关代码无需改动一行即可直接复用。
Message
消息弹窗和 toast 不能运行在小程序端,我们通过接口兼容实现代码复用:
/**
* @author z0000
* @version 1.0
* message 弹窗,api 接口参考 antd,小程序向此接口兼容
*/
import Taro from '@tarojs/taro';
import log from './log';
const message = {
info(content: string, duration = 1500) {
Taro.showToast({ title: content, icon: 'none', duration })
.catch(e => log.error('showToast error: ', e));
},
success(content: string, duration = 1500) {
Taro.showToast({ title: content, icon: 'success', duration })
.catch(e => log.error('showToast error: ', e));
},
warn(content: string, duration = 1500) {
Taro.showToast({ title: content, icon: 'none', duration })
.catch(e => log.error('showToast error: ', e));
},
error(content: string, duration = 1500) {
Taro.showToast({ title: content, icon: 'none', duration })
.catch(e => log.error('showToast error: ', e));
},
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
loading(content: string, _duration = 1500) {
Taro.showLoading({ title: content })
.catch(e => log.error('showLoading error: ', e));
},
destroy() {
Taro.hideLoading();
}
};
export default message;
这里接口参考的 Antd 的 Message API,实现浏览器端和小程序端的兼容。
History
小程序端 history 机制和浏览器端不一样,为了代码复用,我们将小程序路由 API 转换适配浏览器端接口(react router 的 history 方法):
/**
* common api 小程序向 react router 的 history 方法兼容
*/
import Taro from '@tarojs/taro';
import log from "./log";
const history = {
// TODO: 增加query对象方法
push(path: string) {
Taro.navigateTo({ url: '/pages' + path }).catch(e => log.error('navigateTo fail: ', e));
},
replace(path: string) {
Taro.redirectTo({ url: path }).catch(e => log.error('redirectTo fail: ',e));
},
go(n: number) {
if (n >= 0) {
console.error('positive number not support in wx environment');
return;
}
Taro.navigateBack({ delta: -1 * n }).catch(e => log.error('navigateBack fail: ',e));
},
goBack() {
Taro.navigateBack({ delta: 1 }).catch(e => log.error('navigateBack fail: ',e));
}
};
export default history;
之后批量搜索代码中 useHistory
相关 hook 代码,转换为上述实现即可。
Router
小程序端不能直接使用 react-router 类似的路由管理方案,受益于代码模块化分割,大部分代码并没有耦合 react-router-dom 相关的东西,最多的就是 <Link>
组件,这里我们小小改造一下 Link 组件,批量替代即可:
import { FC, useCallback } from 'react';
import Taro from '@tarojs/taro';
import { View } from '@tarojs/components';
import { LinkProps } from 'react-router-dom';
const Index: FC<LinkProps> = ({ to, ...props}) => {
const onClick = useCallback(e => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages' + to as string });
}, [to]);
// @ts-ignore
return <View {...props} onClick={onClick}>{props.children}</View>
};
export default Index;
需要注意的是 Taro.navigateTo
不能直接跳转 Tab 页面,所有最终代码完成后需要 search + 测试覆盖检查相关问题。当然,你也可以在上面代码中检查 to 参数是否为 tab 页面,切换成 Taro.switchTab
方法。
Path Params
小程序不支持类似 /post/:id
的路由参数,我们需要将路由参数转换为:/post?id=xx
,这个转换通过 IDE 搜索,批量 replace 即可。
CSS
由于小程序端的 rpx 单位、px 单位直接使用会有很大的复用问题,导致网页端往小程序端迁移时需要大量改造 HTML 代码,这里我们使用 sass 实现了 tailwindcss 类似的功能(针对小程序端进行改造),通过变量开关切换单位,可以做到不同设计稿代码也能兼容(375px 和 750px 或者 rpx,rem 单位都可以直接兼容)。
设计复用有时比代码复用更加重要,这是用户体验一致性的前提,幸运的是 tailwincss 之类的方案选型让我们很容易做到这一点,我们后续将开源小程序端 tailwindcss 代码,敬请期待。
团队协作
协作也是很重要的一环,产品成功离不开高效合作,我们使用 google doc 全家桶进行协作,包括项目文档、需求、任务管理、邮件,google 全家桶最大的好处就是多端支持,这是目前支持终端最多、协作最方便的工具。linux + android + ios + ipad + windows + mac 都能无缝同步协作。方便设计师、产品经理、程序员共同工作。
最后
欢迎各位体验~