黑客說:如何做到 4 天上線一個小程式?

HD_Superman發表於2021-08-07

自 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}`);
}

小程式端特殊適配

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 都能無縫同步協作。方便設計師、產品經理、程式設計師共同工作。

最後

hackertalk

歡迎各位體驗!

黑客說網頁版

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

微信小程式搜尋:黑客說,或者掃碼:

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章