Taro/TS 快捷開發豐客多裂變小程序

本文共 13092 字,閱讀本文大概須要 10~15 分鐘, 技術乾貨在文章中段,Taro 熟練使用者可跳過前面介紹篇幅
  • 文章目錄
  • 項目背景
  • 項目展現
  • 技術選型css

    • Taro
    • 豐富的 Taro UI 組件庫
  • 項目架構html

    • Taro 與原生小程序融合
    • TypeScript 的實踐
    • MobX 狀態管理
    • API Service、HttpClient 封裝
    • 圖片等比例縮放
    • 海報分享(分享朋友圈)
  • 總結

項目背景

豐客可能是企業業務事業部打造的企業會員制商城,2020 年預期在 Q3 作商城的全面推廣,用戶增加的任務很是艱鉅,所以但願借力 C 端用戶的強社交屬性,以微信小程序爲載體,實現我的推薦企業( C 拉 B )的創新裂變模式。前端

項目展現

圖1
圖2
圖3

技術選型

下方多終端熱門框架對比,能夠看到 Taro 已經同時支持了 React、Vue 技術棧,相較而言考慮到後期維護成本、框架響應維護速度,所以採用團隊自研 Taro 框架

image

Taro

Taro 是由 JDC·凹凸實驗室 傾力打造的一款多端開發解決方案的框架工具,支持使用 React/Vue/Nerv 等框架來開發微信/京東/百度/支付寶/字節跳動/ QQ 小程序/H5 等應用。現現在市面上端的形態多種多樣,Web、React Native、微信小程序等各類端大行其道,當業務要求同時在不一樣的端都要求有所表現的時候,針對不一樣的端去編寫多套代碼的成本顯然很是高,這時候只編寫一套代碼就可以適配到多端的能力就顯得極爲須要。react

當前 Taro 已進入 3.x 時代,相較於 Taro 1/2 採用了重運行時的架構,讓開發者能夠得到完整的 React/Vue 等框架的開發體驗,具體請參考《小程序跨框架開發的探索與實踐》typescript

  • 基於 React、Vue 語法規範,上手幾乎0成本,知足基本開發需求
  • 支持 TS,支持 ES7/ES8 或更新的語法規範
  • 支持 CSS 預編譯器,Sass/Less 等
  • 支持 Hooks (平常開發幾乎不須要 redux 場景)
  • 支持狀態管理,Redux/MobX

豐富的 Taro UI 組件庫

Taro UI 是一款基於 Taro 框架開發的多端 UI 組件庫,一套組件能夠在 微信小程序,支付寶小程序,百度小程序,H5 多端適配運行(ReactNative 端暫不支持)提供友好的 API,可靈活的使用組件。編程

支持必定程度的樣式定製。(請確保微信基礎庫版本在 v2.2.3 以上)目前支持三種自定義主題的方式,能夠進行不一樣程度的樣式自定義:redux

  • scss 變量覆蓋
  • globalClass 全局樣式類
  • 配置 customStyle 屬性(僅有部分組件支持,請查看組件文檔,不建議使用)

項目架構

在前端架構方面,總體架構設計以下:canvas

Taro 與原生小程序融合

項目中須要接入公用的 京東登陸 等其它微信小程序插件來實現登陸態打通,那麼此時咱們就遇到一個問題,多端轉換的問題 Taro 幫咱們作了,可是第三方的這些插件邏輯調用轉換須要咱們本身來實現。那麼面對此場景,咱們採用瞭如下解決方案:小程序

首先 process.env.TARO_ENV 是關鍵,Taro 在編譯運行時候會對應設置該變量 h五、weapp、alipay、tt ...等,全部咱們能夠根據不一樣的變量來調用不一樣的插件。這種場景咱們能夠簡單運用一個工廠模式來處理此邏輯。下面先簡單上圖概述一下windows

  1. 建立抽象 Plugin 類,定製具體插件功能調用方法
  2. 建立實現類(微信小程序、京東小程序、H5 等 )
  3. 建立代工廠類(對外暴露具體方法),初始化時,根據當前場景實例化對應類
/** 抽象類 Plugin 提供具體插件功能 API */
abstract class Plugin {
    abstract getToken(): void; /** 獲取token信息 */   
    abstract outLogin(): void; /** 退出登陸 */   
    abstract openLogin(): void; /** 打開登陸頁 */   
}
/** 方法實現類-小程序 */
class WeChatPlugin extends Plugin {
    getToken(): void {
        // ... 調用對應插件API
    }
    outLogin(): void {
        // ... 調用對應插件API
    }
    openLogin(): void {
        // ... 調用對應插件API
    }
    ...
}
/** 方法實現類-京東小程序 */
class JDPlugin extends Plugin {
    getToken(): void {
        // ... 調用對應插件API
    }
    outLogin(): void {
        // ... 調用對應插件API
    }
    openLogin(): void {
        // ... 調用對應插件API
    }
    ...
}
/** 方法實現類 - H5 */
class H5Plugin extends Plugin {
    getToken(): void {
        // ... 調用對應插件API
    }
    outLogin(): void {
        // ... 調用對應插件API
    }
    openLogin(): void {
        // ... 調用對應插件API
    }
    ...
}
export class pluginHelper {
    private plugin: Plugin;
    constructor() {
        switch (process.env.TARO_ENV) {
            case 'weapp':
                this.plugin = new WeChatPlugin(); 
                break;
            case 'jd':
                this.plugin = new JDPlugin(); 
                break;
            case 'h5':
                this.plugin = new H5Plugin(); 
                break;
                // ...
            default:
                break;
        }
    }
    // 檢查是否爲原生 APP
    get plugin(): Plugin{
        return this.plugin;
    }
}
export default pluginHelper;

TypeScript 的實踐

State Class 約束,非 interface 約束

搜索了一番市面上 React + TS 都是採用 interface 配合使用,下面咱們舉個栗子看一下,看一下缺點

state + interface
interface ITsExampleState {
  /** 名稱 */
  name: string
  name2: string,
  name3: string,
  name4: string,
}
export default class TsExample extends Component<ITsExampleState> {
  state: Readonly<ITsExampleState> = {
    name: "",
    name2: "",
    name3: "",
    name4: "",
    //...
  }
  componentDidShow() {
    let tempState: ITsExampleState = {
      name: '456',
      name2: "",
      name3: "",
      name4: "",
    };
    this.setState(tempState)
  }
  componentDidHide() {
    let tempState: ITsExampleState = {
      name: '456',
      name2: "",
      name3: "",
      name4: "",
    };
    this.setState(tempState)
  }
}

那麼這種方式使用雖然問題,可是咱們會發現每次使用時都須要把每個接口變量初始賦值一下,不然就會報錯,若是10多個變量就須要寫10次,豈不是很麻煩。

看一下,我如何來優雅解決這種場景

state + class

class ITsExampleState {
  /** 名稱 */
  name: string = ""
  name2: string = ""
  name3: string = ""
  name4: string = ""
}
export default class TsExample extends Component<ITsExampleState> {
  state: Readonly<ITsExampleState> = new ITsExampleState();
  componentDidShow() {
    let tempState: ITsExampleState = new ITsExampleState();
    tempState.name = '123';
    this.setState(tempState)
  }
  componentDidHide() {
    let tempState: ITsExampleState = new ITsExampleState();
    tempState.name = '456';
    this.setState(tempState)
  }
}

34行代碼變20行(🤣代碼量 KPI 同窗慎用),代碼量的不一樣差距會愈來愈大,一樣在另外一個小節 API Service 中,再說另外一個優勢。

MobX 狀態管理

[爲何選用 Mobx 不採用 Redux] https://tech.youzan.com/mobx_...

Redux是一個數據管理層,被普遍用於管理複雜應用的數據。可是實際使用中,Redux的表現差強人意,能夠說是很差用。而同時,社區也出現了一些數據管理的方案,Mobx就是其中之一。

MobX 是一個通過戰火洗禮的庫,它經過透明的函數響應式編程(transparently applying functional reactive programming - TFRP)使得狀態管理變得簡單和可擴展。MobX背後的哲學很簡單:

任何源自應用狀態的東西都應該自動地得到。

其中包括UI、數據序列化、服務器通信,等等。

React 和 MobX 是一對強力組合。React 經過提供機制把應用狀態轉換爲可渲染組件樹並對其進行渲染。而MobX提供機制來存儲和更新應用狀態供 React 使用。

對於應用開發中的常見問題,React 和 MobX 都提供了最優和獨特的解決方案。React 提供了優化UI渲染的機制, 這種機制就是經過使用虛擬DOM來減小昂貴的DOM變化的數量。MobX 提供了優化應用狀態與 React 組件同步的機制,這種機制就是使用響應式虛擬依賴狀態圖表,它只有在真正須要的時候才更新而且永遠保持是最新的。

API Service、HttpClient 封裝

面向對象(封裝、繼承、多態)整個項目開發過程當中,服務端是經過判斷請求頭中攜帶的 Header 自定義值來校驗登陸態。每一次數據請求,都須要在請求 Header 上添加自定義字段,隨着接口數量愈來愈多,所以咱們將 Http 請求單獨封裝爲一個模塊。

爲了解決這一問題,咱們將 HTTP 請求統一配置,生成 HttpClient Class 類,對外暴露 post 、 get 方法。並對後臺返回的數據進行統一處理,從新定義返回狀態碼,避免後端狀態碼多樣性,即便後端狀態碼作了修改,也不影響前端代碼的正確運行。

import Taro, { request } from "@tarojs/taro";

const baseUrl = "https://xxxxx"
const errorMsg = '系統有點忙,耐心等會唄';
export class HttpClient {
    /**
     * 檢查狀態
     * @param {ResponseData} response 響應值
     */
    private checkStatus(response) {
        // 若是http狀態碼正常,則直接返回數據
        if (response && (response.statusCode === 200 || response.statusCode === 304 || response.statusCode === 400)) {
            response.data = response.data);
            let resData: ResponseData = { state: 0, value: response.data.xxx, message: response.data.xxx };
            if (response.data.xxx) {
            } else {
                resData.state = 1;
                resData.value = response.data;
                resData.message = response.data.xxx;
            }
            if (resData.state == 1) {
                Taro.showToast({
                    title: resData.message,
                    icon: 'none',
                    duration: 2000
                })
            }
            return resData
        } else {
            Taro.showToast({
                title: errorMsg,
                icon: 'none',
                duration: 2000
            })
            return null
        }
    }

    public post(url: string, params: any = {}) {
        return this.request('post', url, params)
    }
    public get(url: string, params: any = {},) {
        return this.request('get', url, params)
    }

    async checkNetWorkDiasble() {
        return new Promise((resolve, reject) => {
            Taro.getNetworkType({
                success(res) {
                    const networkType = res.networkType
                    resolve(networkType == 'none')
                }
            })
        })
    }

    /**
    * request請求
    * @param {string} method get|post
    * @param {url} url 請求路徑
    * @param {*} [params] 請求參數
    */
    private async request(method: string, apiUrl: string, params: any): Promise<ResponseData | null> {
        // Taro request ...
    }
}

/**
 * 內部 響應對象
 * @param {number} state 0 成功 1失敗
 * @param {any} value 接口響應數據
 * @param {string} message 服務器響應信息msg
 */
interface ResponseData {
    state: number;
    value?: any;
    message: string;
}

對於 HTTP 請求咱們仍是不知足,在組件中咱們調用 HttpClient Class 類進行數據請求時,咱們依然要回到請求接口的 Service 模塊文件,查看入參,或者是查看 swagger 文檔,如何才能一目了
然呢?採用 Class Params 對象方式約束入參,從編譯方式上進行約束。咱們如下請求爲例:

class UserApiService {
    // ...
    getFansInfo(params: PageParams) {
        return this.httpClient.post('/user/xxx', params);
    }
}
export class PageParams {
    /** 請求頁 */
    pageNo: number = 1;
    /** 請求數量 */
    pageSize: number = 10;
}

export class Test{
    testFn(){
        // 獲取粉絲數據
        let pageParams:PageParams=new PageParams();
        pageParams.pageNo = 1;
        pageParams.pageNo = 10;
        this.userApiService.getFansInfo(pageParams).then(res => {});
    }
}

在 getFansInfo 方法中,咱們經過 TypeScript 的方式,約束了接口的參數是一個對象。同時在調用過程當中能夠採用 . 對應的屬性,友好的查看註釋,非 interface 使用

是否是很方便,不但避免了參數類型的不一致,出現 bug ,也節省了查找方法的時間,提升開發效率!

注:在 VS code 的編輯器中,當鼠標移動到某些文本以後,稍做片刻就會出現一個懸停提示窗口,這個窗口裏會顯示跟鼠標下文本相關的信息。若是想要查看對象就具體信息,須要按下 Cmd 鍵( Windows 上是 Ctrl )。

圖片等比例縮放

在咱們的項目中首頁採用瀑布流圖片,並採用不規則高度圖片,可是在咱們的小程序中 Image 標籤又必須設置高度,這可如何是好...
咱們經過 onLoad 函數來進行等比例縮放
export default class Index extends Component {
    // ...
    render() {
        const { imageUrl,imageHeight } = this.state as IState;
        return (
                <Image
                    mode="aspectFill"
                    style={`height:${imageHeight}px`}
                    src={imageUrl}
                    onLoad={this.imageOnload(event)} >
                </Image>
        );
    }
    imageOnload = (e)=>{
        let res = Utils.imageScale(e)
        this.setState({
            imageHeight: res.imageHeight;
        })
    }
}

export default class Utils {
    static imageScale = (e) => {
        let imageSize = {
            imageWidth: 0,
            imageHeight: 0
        };
        let originalWidth = e.detail.width;//圖片原始寬
        let originalHeight = e.detail.height;//圖片原始高
        let originalScale = originalHeight / originalWidth;//圖片高寬比
        // console.log('originalWidth: ' + originalWidth)
        // console.log('originalHeight: ' + originalHeight)
        //獲取屏幕寬高
        let res = Taro.getSystemInfoSync();
        let windowWidth = res.windowWidth;
        let windowHeight = res.windowHeight;
        let windowscale = windowHeight / windowWidth;//屏幕高寬比
        // console.log('windowWidth: ' + windowWidth)
        // console.log('windowHeight: ' + windowHeight)
        if (originalScale < windowscale) {//圖片高寬比小於屏幕高寬比
            //圖片縮放後的寬爲屏幕寬
            imageSize.imageWidth = windowWidth;
            imageSize.imageHeight = (windowWidth * originalHeight) / originalWidth;
        } else {//圖片高寬比大於屏幕高寬比
            //圖片縮放後的高爲屏幕高
            imageSize.imageHeight = windowHeight;
            imageSize.imageWidth = (windowHeight * originalWidth) / originalHeight;
        }
        // console.log('縮放後的寬: ' + imageSize.imageWidth)
        // console.log('縮放後的高: ' + imageSize.imageHeight)
        return imageSize;
    }
}

海報分享

在微信中小程序沒法分享到朋友圈,目前大部分的解決方案都是,Canvas 動態繪製生成圖片後,保存到用戶相冊,用戶進行分享照片到朋友圈,朋友圈打開圖片後識別二維碼進入小程序,達到分享目的。
下面帶你們實現實現一波:

  1. 海報分析

  1. 代碼 Canvas 初始化建立
<Canvas style={`height:${canvasHeight}px;width:${canvasWidth}px`} className='shareCanvas' canvas-id="shareCanvas" ></Canvas>
  1. 樣式設置

保證 Canvas 不在用戶的視線內

.shareCanvas {
    width: 100%;
    height: 100%;
    background: #fff;
    position: absolute;
    opacity: 0;
    z-index: -1;
    right: 2000rpx;
    top: 2000rpx;
    z-index: 999999;
}
  1. CanvasUtil 工具類
export class CanvasUtil {
  /**
   * canvas 文本換行計算
   * @param {*} context CanvasContext
   * @param {string} text 文本
   * @param {number} width 內容寬度
   * @param {font} font 字體(字體大小會影響寬)
   */
  static breakLinesForCanvas(context, text: string, width: number, font) {
    function findBreakPoint(text: string, width: number, context) {
      var min = 0;
      var max = text.length - 1;
      while (min <= max) {
        var middle = Math.floor((min + max) / 2);
        var middleWidth = context.measureText(text.substr(0, middle)).width;
        var oneCharWiderThanMiddleWidth = context.measureText(text.substr(0, middle + 1)).width;
        if (middleWidth <= width && oneCharWiderThanMiddleWidth > width) {
          return middle;
        }
        if (middleWidth < width) {
          min = middle + 1;
        } else {
          max = middle - 1;
        }
      }

      return -1;
    }


    var result = [];
    if (font) {
      context.font = font;
    }
    var textArray = text.split('\r\n');
    for (let i = 0; i < textArray.length; i++) {
      let item = textArray[i];
      var breakPoint = 0;
      while ((breakPoint = findBreakPoint(item, width, context)) !== -1) {
        result.push(item.substr(0, breakPoint));
        item = item.substr(breakPoint);
      }
      if (item) {
        result.push(item);
      }
    }
    return result;
  }
  /**
   * 圖片裁剪畫圓
   * @param {*} ctx CanvasContext
   * @param {string} img 圖片
   * @param {number} x x軸 座標
   * @param {number} y y軸 座標
   * @param {number*} r 半徑
   */
  static circleImg(ctx, img: string, x: number, y: number, r: number) {
    ctx.save();
    ctx.beginPath()
    var d = 2 * r;
    var cx = x + r;
    var cy = y + r;
    ctx.arc(cx, cy, r, 0, 2 * Math.PI);
    ctx.clip();
    ctx.drawImage(img, x, y, d, d);
    ctx.restore();
  }
  /**
   * 繪製圓角矩形
   * @param {*} ctx CanvasContext
   * @param {number} x x軸 座標
   * @param {number} y y軸 座標
   * @param {number} width 寬
   * @param {number} height 高
   * @param {number} r r 圓角
   * @param {boolean} fill 是否填充顏色
   */
  static drawRoundedRect(ctx, x: number, y: number, width: number, height: number, r: number, fill: boolean) {
    ctx.beginPath();
    ctx.arc(x + r, y + r, r, Math.PI, Math.PI * 3 / 2);
    ctx.lineTo(width - r + x, y);
    ctx.arc(width - r + x, r + y, r, Math.PI * 3 / 2, Math.PI * 2);
    ctx.lineTo(width + x, height + y - r);
    ctx.arc(width - r + x, height - r + y, r, 0, Math.PI * 1 / 2);
    ctx.lineTo(r + x, height + y);
    ctx.arc(r + x, height - r + y, r, Math.PI * 1 / 2, Math.PI);
    ctx.closePath();
    if (fill) {
      ctx.fill();
    }
  }
}
export default CanvasUtil;
  1. JS 邏輯處理
/** 用戶微信頭像 */
let avatarUrl = 'https://xxx.360buyimg.com/xxxxx.png';
// 海報背景圖片
let inviteImageUrl = 'https://xxx.360buyimg.com/xxxxx.png';
// 二維碼背景白尺寸
let qrBgHeight = 85;
let qrBgWidth = 85;
// 圖片居中尺寸
let centerPx = canvasWidth / 2;
// 二維碼背景白 x軸 ,y軸 座標
let qrBgX = centerPx - qrBgWidth / 2;
let qrBgY = 370;
let context = Taro.createCanvasContext('shareCanvas');
//海報背景繪製
context.drawImage(inviteImageUrl, 0, 0, canvasWidth, canvasHeight);
//矩形顏色設置
context.setFillStyle('#ffffff');
//繪製二維碼圓角矩形
CanvasUtil.drawRoundedRect(context, qrBgX, qrBgY, qrBgWidth, qrBgHeight, 5, true);
// context.restore();
//繪製二維碼
context.drawImage(this.downloadQRcode, qrBgX + 2, qrBgY + 2, qrBgWidth - 4, qrBgHeight - 4);
// 下載微信頭像到本地
Taro.downloadFile({
    url: avatarUrl,
    success: function (res) {
    // 微信頭像尺寸尺寸
    let wxAvatarHeight = 32;
    let wxAvatarWidth = 32;
    // 微信頭像居中 x軸 ,y軸 座標
    let wxAvatarX = centerPx - wxAvatarWidth / 2;
    let wxAvatarY = 395.5;
    //微信頭像繪製
    CanvasUtil.circleImg(context, res.tempFilePath, wxAvatarX, wxAvatarY, wxAvatarWidth / 2);
    // 文本繪製
    context.setTextAlign("center")
    context.font = "12px PingFangSC-Regular";
    context.fillText("掃一掃", centerPx, qrBgY + qrBgHeight + 20);

    context.font = "10px PingFangSC-Regular";
    context.fillText("當即註冊豐客多", centerPx, qrBgY + qrBgHeight + 34);

    context.draw();

    Taro.showLoading({
        title: '生成中',
    })
    setTimeout(() => {
        Taro.canvasToTempFilePath({
        canvasId: 'shareCanvas',
        fileType: 'jpg',
        success: function (res) {
            Taro.hideLoading()
            console.log(res.tempFilePath)
            Taro.showLoading({
              title: '保存中...',
              mask: true
            });
            Taro.saveImageToPhotosAlbum({
              filePath: res.tempFilePath,
              success: function (res) {
                Taro.showToast({
                  title: '保存成功',
                  icon: 'success',
                  duration: 2000
                })
              },
              fail: function (res) {
                Taro.hideLoading()
                console.log(res)
              }
            })
        }
        })
    }, 1000);
    }
})

總結

在開發此項目以前,都是本身都是採用原生微信小程序進行開發,該項目是我第一次使用 Taro + Taro UI + TypeScript 來開發小程序,在開發過程當中經過查閱官方文檔,基本屬於 0 成本上手。同時在開發過程當中遇到問題一點一滴記錄下來,從而獲得成長,並沉澱出此文章,達到提升自我幫助他人。目前 Taro 框架也在不斷的迭代中,在近期發佈的 3.0 候選版本也已經支持使用 Vue 語言,做爲一個支持多端轉化的工具框架值得你們選擇。

相關文章
相關標籤/搜索