本文共 13092 字,閱讀本文大概須要 10~15 分鐘, 技術乾貨在文章中段,Taro 熟練使用者可跳過前面介紹篇幅
技術選型css
項目架構html
豐客可能是企業業務事業部打造的企業會員制商城,2020 年預期在 Q3 作商城的全面推廣,用戶增加的任務很是艱鉅,所以但願借力 C 端用戶的強社交屬性,以微信小程序爲載體,實現我的推薦企業( C 拉 B )的創新裂變模式。前端
下方多終端熱門框架對比,能夠看到 Taro 已經同時支持了 React、Vue 技術棧,相較而言考慮到後期維護成本、框架響應維護速度,所以採用團隊自研 Taro 框架
Taro 是由 JDC·凹凸實驗室 傾力打造的一款多端開發解決方案的框架工具,支持使用 React/Vue/Nerv 等框架來開發微信/京東/百度/支付寶/字節跳動/ QQ 小程序/H5 等應用。現現在市面上端的形態多種多樣,Web、React Native、微信小程序等各類端大行其道,當業務要求同時在不一樣的端都要求有所表現的時候,針對不一樣的端去編寫多套代碼的成本顯然很是高,這時候只編寫一套代碼就可以適配到多端的能力就顯得極爲須要。react
當前 Taro 已進入 3.x 時代,相較於 Taro 1/2 採用了重運行時的架構,讓開發者能夠得到完整的 React/Vue 等框架的開發體驗,具體請參考《小程序跨框架開發的探索與實踐》。typescript
Taro UI 是一款基於 Taro 框架開發的多端 UI 組件庫,一套組件能夠在 微信小程序,支付寶小程序,百度小程序,H5 多端適配運行(ReactNative 端暫不支持)提供友好的 API,可靈活的使用組件。編程
支持必定程度的樣式定製。(請確保微信基礎庫版本在 v2.2.3 以上)目前支持三種自定義主題的方式,能夠進行不一樣程度的樣式自定義:redux
在前端架構方面,總體架構設計以下:canvas
項目中須要接入公用的 京東登陸
等其它微信小程序插件來實現登陸態打通,那麼此時咱們就遇到一個問題,多端轉換的問題 Taro 幫咱們作了,可是第三方的這些插件邏輯調用轉換須要咱們本身來實現。那麼面對此場景,咱們採用瞭如下解決方案:小程序
首先 process.env.TARO_ENV
是關鍵,Taro 在編譯運行時候會對應設置該變量 h五、weapp、alipay、tt ...等,全部咱們能夠根據不一樣的變量來調用不一樣的插件。這種場景咱們能夠簡單運用一個工廠模式來處理此邏輯。下面先簡單上圖概述一下windows
/** 抽象類 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;
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 不採用 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 組件同步的機制,這種機制就是使用響應式虛擬依賴狀態圖表,它只有在真正須要的時候才更新而且永遠保持是最新的。
面向對象(封裝、繼承、多態)整個項目開發過程當中,服務端是經過判斷請求頭中攜帶的 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 動態繪製生成圖片後,保存到用戶相冊,用戶進行分享照片到朋友圈,朋友圈打開圖片後識別二維碼進入小程序,達到分享目的。
下面帶你們實現實現一波:
<Canvas style={`height:${canvasHeight}px;width:${canvasWidth}px`} className='shareCanvas' canvas-id="shareCanvas" ></Canvas>
保證 Canvas 不在用戶的視線內
.shareCanvas { width: 100%; height: 100%; background: #fff; position: absolute; opacity: 0; z-index: -1; right: 2000rpx; top: 2000rpx; z-index: 999999; }
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;
/** 用戶微信頭像 */ 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 語言,做爲一個支持多端轉化的工具框架值得你們選擇。