JavaScript 黑客说:如何做到 4 天上线一个小程序?

zhd_superman · 2021年08月07日 · 最后由 lidashuang 回复于 2022年05月18日 · 1251 次阅读

自 6 月 6 号上线“黑客说”网页版(hackertalk.net)以来吸引了很多用户,为了进一步完善终端体验,我们决定复用已有的技术栈,实现微信端小程序,前后开发仅花了 4 天,本文主要从技术的角度讨论我们如何快速上线小程序。

黑客说是什么?

这是我们专门为程序员群体定制的交流平台,有及时技术资讯、高质量技术问答、实用编程经验分享,还有程序员的日常生活。接近 500 个编程相关话题。

一个高度定制的 Markdown 编辑器:所见即所得,再也不用分屏预览了~

网页版编辑器:插入 latex 公式

网页版编辑器:插入 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}`);
}

小程序端特殊适配

由于小程序端无法支持 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 都能无缝同步协作。方便设计师、产品经理、程序员共同工作。

最后

hackertalk

欢迎各位体验!

黑客说网页版

HackerTalk(黑客说)第一帖:Happy hacking!

微信小程序搜索:黑客说,或者扫码:

厉害了!

最快的方式不是用 web-view 吗?

zhongsheng 回复

web-view 一些小程序功能无法实现

需要 登录 后方可回复, 如果你还没有账号请 注册新账号