- 小程序实现高性能虚拟列表优化+节流+分页请求(固定高度)
场景引入 为什么需要用到高性能虚拟列表+节流+分页请求的优化? 当有场景需求为需要将大量数据(10000条)呈现在一页上,我们不断下拉访问,页面中有大量的数据列表的时候,用户会不会有不好的体验?会不会出现滚动不流畅而卡顿的情况?会不会因卡顿而出现短暂的白屏现象(数据渲染不成功)? 通过微信开发者工具自带的调试器->Network页面,我们可以观察到当有长列表时如果不使用高性能虚拟列表+节流+分页请求的优化,会出现以下问题: FPS:每秒帧数,图表上的红色块表示长时间帧,很可能会出现卡顿。 CPU:CPU消耗占用,实体图越多消耗越高。 NET:网络请求效率低,一次性请求10000条的渲染效率远远低于分1000次,每次请求10条数据 内存:滑动该列表时明显能看到内存消耗大。 总结:如果需要将大量数据(10000条)呈现在一页上,可以通过高性能虚拟列表+节流+按需请求分页数据并追加显示。 优化的具体实现可拆分为以下需求(将一个大问题拆分为一个个小问题并逐个去解决): 不把长列表数据一次性全部直接显示在页面上。 截取长列表一部分数据用来填充屏幕容器区域。 长列表数据不可视部分使用使用空白占位填充。 监听滚动事件根据滚动位置动态改变可视列表。 监听滚动事件根据滚动位置动态改变空白填充。 分页从服务器请求数据,将一次性请求所有数据变为滚动到底部才再次向服务器发送获取数据的请求 开始实战 此次实例我设定每个元素的固定高度为210rpx [图片] (1)首先计算屏幕内的容积最大容量(即屏幕一次性可以容纳多少个高度为210rpx的元素) 调用wx.createSelectorQuery()的api [图片] 上图是id为scrollContainer的组件,滚动时触发函数handleScroll() [代码]// 计算容器的最大容积,onReady中触发,即初次渲染时触发 getContainSize() { wx.createSelectorQuery().select('#scrollContainer').boundingClientRect(function (rect) { rect.id // 节点的ID rect.dataset // 节点的dataset rect.left // 节点的左边界坐标 rect.right // 节点的右边界坐标 rect.top // 节点的上边界坐标 rect.bottom // 节点的下边界坐标 rect.width // 节点的宽度 rect.height // 节点的高度 }).exec((option) => { // console.log(~~(option[0].height / this.data.oneHeight) + 2); this.data.containSize = ~~(option[0].height / this.data.oneHeight) + 2; }) }, [代码] 调用api后返回的option中的height就是小程序页面的视口高度,除以oneHeight(210rpx)就是能容纳的个数,用[代码]~~[代码]来对结果进行向下取整(实际能容纳的应+1),由于在滚动时会出现上一个元素在上边界还没完全消失,第四个元素就从下边界进入视口了,因此最大容纳量应再+1。即实际容纳量应该如图最后一行代码所示+2. [图片] (2)监听滚动事件动态截取数据 监听用户滚动、滑动事件,根据滚动位置,动态计算当前可视区域起始数据的索引位置 startIndex,再根据containsize,计算结束数据的索引位置 endIndex,最后根据 startIndex与endIndex截取长列表所有数据a11Datalist 中需显示的数据列表 showDatalist。 PS:下列代码中函数handleScroll()最下面的[代码]this.setDataStartIndex(data);[代码]才是滚动时真正进行的滚动事件动态截取数据,上面那些代码用途在文章后面部分再详细介绍(节流)。 [代码]// 定义滚动行为事件方法 handleScroll(data) { if (this.data.isScrollStatus) { this.data.isScrollStatus = false; // 节流,设置一个定时器,1秒以后,才允许进行下一次scroll滚动行为 let mytimer = setTimeout(() => { this.data.isScrollStatus = true; clearTimeout(mytimer); }, 17) this.setDataStartIndex(data); } }, [代码] [代码]// 执行数据设置的相关任务, 滚动事件的具体行为 setDataStartIndex(data) { // console.log("scroll active") this.data.startIndex = ~~(data.detail.scrollTop / this.data.oneHeight); // 通过scrollTop滑动后距离顶部的高度除以每个元素的高度,即可知道目前到第几个元素了 this.setData({ showDataList: this.data.allDatalist.slice(this.data.startIndex, this.data.endIndex) }) // 动态截取实际拥有10000条数据的数组中下标为startIndex到endIndex的数组出来呈现在前端页面上 // 容器最后一个元素的索引 if (this.data.startIndex + this.data.containSize <= this.data.allDatalist.length - 1) { this.setData({ endIndex: (!this.data.allDatalist[this.data.endIndex]) ? this.data.allDatalist.length - 1 : this.data.startIndex + this.data.containSize // 滚动到底部了吗,是的话那就将endIndex设置为9999,不然的话设置为startIndex+视口最大容量 }) } else { console.log("滚动到了底部"); this.data.pageNumNow++; // 例如一次性从数据库拿10条数据赋值到allDataList,如果滚动到底部(即allDataList所有数据都已经呈现了),那就再次向服务器发送请求获取数据库中的下10条数据 this.addMes(); // 该函数内就写你实际向数据库请求时的代码,请求成功后拼接到allDataList即可 console.log(this.data.allDatalist.length) } }, [代码] (3)使用计算属性动态设置上下空白占位 我们设置了根据容器滚动位移动态截取ShowDatalist 数据,现在我们滚动一下发现滚动2条列表数据后,就无法滚动了,这个原因是什么呢? 在容器滚动过程中,因为动态移除、添加数据节点丢失,进而强制清除了顶部列表元素DOM节点,导致滚动条定位向上移位一个列表元素高度,进而出现了死循环根据 startIndex和endIndex的位置,使用计算属性,动态的计算并设置,上下空白填充的高度样式blankFi11Sty1e,使用padding或者margin 进行空白占位都是可以的 PS:由于小程序没有computed,所以为了使用计算属性,得另外引入封装好computed的包,引入computed组件的教程我放在最后,我使用的是官方推荐的computed,且注意使用该插件时,不要加[代码]this -> this.data[代码],直接data即可 [代码]computed: { // 定义上空白高度 topBlankFill(data) { // console.log("change") return data.startIndex * data.oneHeight; }, // 定义下空白高度 BottomBlankFill(data) { return (data.allDatalist.length - data.endIndex) * data.oneHeight; }, // 定义一个 待显示的数组列表元素 showDataList(data) { // console.log(data.allDatalist.slice(data.startIndex, data.endIndex)) return data.allDatalist.slice(data.startIndex, data.endIndex) }, }, [代码] (4)下拉置底自动请求加载数据 下方代码中[代码]setDataStartIndex()[代码]函数末尾的if-else便是判断是否已经滚动到现有全部数据的allDataList数组是否已经滚动到底部,全部呈现完了。 如果是那就执行else部分,请求的页数pageNumNow+1,然后调用addMes()请求数据,然后将新请求到的数据进行拼接到allDataList上 [代码]// 执行数据设置的相关任务, 滚动事件的具体行为 setDataStartIndex(data) { // console.log("scroll active") this.data.startIndex = ~~(data.detail.scrollTop / this.data.oneHeight); this.setData({ showDataList: this.data.allDatalist.slice(this.data.startIndex, this.data.endIndex) }) // 容器最后一个元素的索引 if (this.data.startIndex + this.data.containSize <= this.data.allDatalist.length - 1) { this.setData({ endIndex: (!this.data.allDatalist[this.data.endIndex]) ? this.data.allDatalist.length - 1 : this.data.startIndex + this.data.containSize }) } else { console.log("滚动到了底部"); this.data.pageNumNow++; this.addMes(); console.log(this.data.allDatalist.length) } }, // 根据接口数据来给数组添加真实数据 addMes: function () { this.list() .then(res => { // console.log(res.result.data); // 将接口获取到得所有数据存储起来 this.data.allDatalist = this.data.allDatalist.concat(res.result.data); // 设置初始显示列表 this.setData({ showDataList: this.data.allDatalist.slice(0, 5) }) }) }, // 以下为获取数据 list: function () { let pageNum = this.data.pageNumNow; let pageSize = 20; // console.log('当前请求的页码为:' + pageNum); return new Promise((resolve, reject) => { wx.cloud.callFunction({ name: 'teacher', data: { action: 'list', pageNum, pageSize } }).then(res => { resolve(res) }).catch(err => { reject(err) }) }) }, [代码] 到此处,高性能虚拟列表+分页请求的优化已经搞定了,下面开始节流的优化 (5)滚动事件节流定时器优化 由于监听滚动事件触发对应函数方法的频率是极高的,因此页面节流优化是必须的。 方法:在data中声明一个属性scro11State用来记录滚动状态,只有scro11State值为true的时候才会具体执行 PS:下面代码中定时器设置为17ms的原因是,由于小程序没有web的requestAnimationFrame的api,无法作滚动事件节流请求动画帧优化,因此可以手动计算显示器的帧率,大概一帧在17ms,因此定时器设置为17ms [代码]// 定义滚动行为事件方法 handleScroll(data) { //节流部分代码 if (this.data.isScrollStatus) { this.data.isScrollStatus = false; // 节流,设置一个定时器,1秒以后,才允许进行下一次scroll滚动行为 let mytimer = setTimeout(() => { this.data.isScrollStatus = true; clearTimeout(mytimer); }, 17) //节流部分代码 this.setDataStartIndex(data); } }, [代码] 到这为止,节流也搞定啦,觉得从中学到了许多的小伙伴不妨点个赞。 小程序如何引入computed计算属性请参考我的这篇文章:https://developers.weixin.qq.com/community/develop/article/doc/000a4442bd44c84e740d6b6b051413 在后续我也会将高性能虚拟列表+节流的优化封装成一个插件,给小伙伴们直接使用,欢迎关注我以便及时获取到我文章的更新~ 在这里也推荐一篇防抖和节流的性能优化知识介绍的文章:https://segmentfault.com/a/1190000018428170 觉得有帮助的小伙伴欢迎点赞,有其他问题也欢迎在评论区提出
2021-11-11 - 小程序如何维护一个队列,并在处理完一个数据后,再取下一个数据处理?
典型应用场景: 支付成功后,对支付结果进行语音播报:xxx 已支付 100 元。 已有部分解决方案: https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/database/realtime.html 云数据库支持数据库数据变化监听,可以返回新增的订单记录。 在监听到订单后,需要调用txttospeech播放语音,但是语音播放是有处理时间的,比如播放需要2秒,那么就会出现同一时间播放多条语音。如下代码: 我的想法是对data中的orders进行监听,处理一个去拿一个,但是要阻塞监听。 const watcher = db.collection('order') // 按 progress 降序 .orderBy('modified', 'desc') // 取按 orderBy 排序之后的前 10 个 // .limit(10) .where({ sid: sid, modified: _.gt(new Date()), status: 'DAIZHIFU' }) .watch({ onChange: function (snapshot) { if (snapshot.type === 'init') { console.log('data type : ' + snapshot.type) } else { let hasNewOrder = false const orders = _this.data.orders var plugin = requirePlugin("WechatSI") for (const docChange of snapshot.docChanges) { switch (docChange.queueType) { case 'enqueue': { console.log("new order wxOrder = " + docChange.doc.wxOrder) hasNewOrder = true orders.unshift(docChange.doc) console.log('orders : ', orders) var sfname = docChange.doc.sfname var price = docChange.doc.price // 分 const fenStr = '00' + Number.parseInt(price.toString()).toString() var priceYuan = Number.parseFloat(fenStr.replace(/^(\d+?)(\d{2})$/g, '$1.$2')) var text = sfname + "收款" + priceYuan + "元" // let manager = plugin.getRecordRecognitionManager() plugin.textToSpeech({ lang: "zh_CN", tts: true, content: text, success: function (res) { console.log("succ tts", res.filename) // 播放声音 var mp3 = res.filename var audio = wx.createInnerAudioContext() audio.src = mp3 audio.autoplay = true audio.onPlay(() => { console.log('开始播放') }) audio.onError((res) => { console.log(res.errMsg) console.log(res.errCode) }) }, fail: function (res) { console.log("fail tts", res) wx.showModal({ showCancel: false, title: 'TTS Fail', content: JSON.stringify(res) }) } }) } } } _this.setData({ orders: orders, hasNewOrder: hasNewOrder }) if (_this.data.hasNewMessage) { _this.scrollToBottom() } } }, onError: function (err) { console.error('the watch closed because of error', err) wx.showModal({ showCancel: false, title: '提示', content: JSON.stringify(err) }) } })
2021-07-25 - 小程序防止重复点击或重复触发事件
可设置全局变量和全局函数,直接使用app唯一实例调用,方便快捷。 在app.js下 App({ globalData: { PageActive: true }, preventActive (fn) { const self = this if (this.globalData.PageActive) { this.globalData.PageActive = false if (fn) fn() setTimeout(() => { self.globalData.PageActive = true }, 1500); //设置该时间内重复触发只执行第一次,单位ms,按实际设置 } else { console.log('重复点击或触发') } } }) 其他page下调用 index.wxml <button bindtap="tap">点击</button> index.js Page({ tap (e) { getApp().preventActive(()=>{ //code... }) } })
2021-01-12 - app.onLaunch与page.onLoad异步问题
问题:相信很多人都遇到过这个问题,通常我们会在应用启动app.onLaunch() 去发起静默登录,同时我们需要在加载页面的时候,去调用一个需要登录态的后端 API 。由于两者都是异步,往往page.onload()调用API的时候,app.onLaunch() 内调用的静态登录过程还没有完成,从而导致请求失败。 解决方案:1. 通过回调函数// on app.js App({ onLaunch() { login() // 把hasLogin设置为 true .then(() => { this.globalData.hasLogin = true; if (this.checkLoginReadyCallback) { this.checkLoginReadyCallback(); } }) // 把hasLogin设置为 false .catch(() => { this.globalData.hasLogin = false; }); }, }); // on page.js Page({ onLoad() { if (getApp().globalData.hasLogin) { // 登录已完成 fn() // do something } else { getApp().checkLoginReadyCallback = () => { fn() } } }, }); ⚠️注意:这个方法有一定的缺陷(如果启动页中有多个组件需要判断登录情况,就会产生多个异步回调,过程冗余),不建议采用。 2. 通过Object.defineProperty监听globalData中的hasLogin值 // on app.js App({ onLaunch() { login() // 把hasLogin设置为 true .then(() => { this.globalData.hasLogin = true; }) // 把hasLogin设置为 false .catch(() => { this.globalData.hasLogin = false; }); }, // 监听hasLogin属性 watch: function (fn) { var obj = this.globalData Object.defineProperty(obj, 'hasLogin', { configurable: true, enumerable: true, set: function (value) { this._hasLogin = value; fn(value); }, get: function () { return this._hasLogin } }) }, }); // on page.js Page({ onLoad() { if (getApp().globalData.hasLogin) { // 登录已完成 fn() // do something } else { getApp().watch(() => fn()) } }, }); 3. 通过beautywe的状态机插件(项目中使用该方法) // on app.js import { BtApp } from '@beautywe/core/index.js'; import status from '@beautywe/plugin-status/index.js'; import event from '@beautywe/plugin-event/index.js'; const app = new BtApp({ onLaunch() { // 发起静默登录调用 login() // 把状态机设置为 success .then(() => this.status.get('login').success()) // 把状态机设置为 fail .catch(() => this.status.get('login').fail()); }, }); // status 插件依赖于 beautywe-plugin-event app.use(event()); // 使用 status 插件 app.use(status({ statuses: [ 'login' ], })); // 使用原生的 App 方法 App(app); // on page.js Page({ onLoad() { // must 里面会进行状态的判断,例如登录中就等待,登录成功就直接返回,登录失败抛出等。 getApp().status.get('login').must().then(() => { // 进行一些需要登录态的操作... }) }, }); 具体实现 具体实现可以参考我的商城小程序项目 项目体验地址:体验 代码:代码
2021-05-20 - 微信小程序页面监听全局变量的改变
app.js------------------------------------------------ watch: function (method) { //监听函数 var obj = this.globalData Object.defineProperty(obj, 'clock', { configurable: true, enumerable: true, set: function (value) { this._name = value; method(value); }, get: function () { return this._name } }) }, globalData: { clock:""//要监听的变量 } ———————————————— index.js------------------------------- onLoad:function(options){ getApp().watch(this.watchBack)//注册监听 }, watchBack: function (value){ //要执行的方法 this.setData({ clock: value }) } 亲测可用。 但是文档上没有不知道为啥,如果有坑请留言哈。
2021-04-28 - 教你怎么监听小程序的返回键
更新:2020年7月28日08:51:11 基础库2.12.0起,可以调用wx.enableAlertBeforeUnload监听原生右上角返回、物理返回以及wx.navigateBack时弹框提示 AIP详情请看: https://developers.weixin.qq.com/miniprogram/dev/api/ui/interaction/wx.enableAlertBeforeUnload.html //======================================== 怎么监听小程序的返回键? 应该有很多人想要监听用户的这个动作吧,但是很遗憾,小程序不会给你这个API的,那是不是就没辙了? 幸好我们还可以自定义导航栏,这样一来我们就可以监听用户的这一动作了。 什么?这你已经知道啦? 那好咱们就不说自定义导航栏的返回监听了,说一下物理返回和左滑?右滑?(不管了,反正是滑)返回上一页怎么监听。 监听物理返回 首先说一下这个监听方法的缺点,虽说是监听,但是还是无法真正意义上的监听并拦截来阻止页面跳转,页面还是会返回上一页,而后重新载入刚刚的页面,如果这不是你想要的,那可以不用往下看了 其次说一下用到什么东西: wx.onAppRoute、wx.showModal 最后是一些主要代码: 重写wx.showModal,主要是加个confirmStay参数和使wx.showModal Promise化 [代码]const { showModal } = wx; Object.defineProperty(wx, 'showModal', { configurable: false, // 是否可以配置 enumerable: false, // 是否可迭代 writable: false, // 是否可重写 value(...param) { return new Promise(function (rs, rj) { let { success, fail, complete, confirmStay } = param[0] param[0].success = (res) => { res.navBack = (res.confirm && !confirmStay) || (res.cancel && confirmStay) wx.setStorageSync('showBackModal', !res.navBack) success && success(res) rs(res) } param[0].fail = (res) => { fail && fail(res) rj(res) } param[0].complete = (res) => { complete && complete(res) (res.confirm || res.cancel) ? rs(res) : rj(res) } return showModal.apply(this, param); // 原样移交函数参数和this }.bind(this)) } }); [代码] 使用wx.onAppRoute实现返回原来的页面 [代码]wx.onAppRoute(function (res) { var a = getApp(), ps = getCurrentPages(), t = ps[ps.length - 1], b = a && a.globalData && a.globalData.pageBeforeBacks || {}, c = a && a.globalData && a.globalData.lastPage || {} if (res.openType == 'navigateBack') { var showBackModal = wx.getStorageSync('showBackModal') if (c.route && showBackModal && typeof b[c.route] == 'function') { wx.navigateTo({ url: '/' + c.route + '?useCache=1', }) b[c.route]().then(res => { if (res.navBack){ a.globalData.pageBeforeBacks = {} wx.navigateBack({ delta: 1 }) } }) } } else if (res.openType == 'navigateTo' || res.openType == 'redirectTo') { if (!a.hasOwnProperty('globalData')) a.globalData = {} if (!a.globalData.hasOwnProperty('lastPage')) a.globalData.lastPage = {} if (!a.globalData.hasOwnProperty('pageBeforeBacks')) a.globalData.pageBeforeBacks = {} if (ps.length >= 2 && t.onBeforeBack && typeof t.onBeforeBack == 'function') { let { onUnload } = t wx.setStorageSync('showBackModal', !0) t.onUnload = function () { a.globalData.lastPage = { route: t.route, data: t.data } onUnload() } } t.onBeforeBack && typeof t.onBeforeBack == 'function' && (a.globalData.pageBeforeBacks[t.route] = t.onBeforeBack) } }) [代码] 改造Page [代码]const myPage = Page Page = function(e){ let { onLoad, onShow, onUnload } = e e.onLoad = (() => { return function (res) { this.app = getApp() this.app.globalData = this.app.globalData || {} let reinit = () => { if (this.app.globalData.lastPage && this.app.globalData.lastPage.route == this.route) { this.app.globalData.lastPage.data && this.setData(this.app.globalData.lastPage.data) Object.assign(this, this.app.globalData.lastPage.syncProps || {}) } } this.useCache = res.useCache res.useCache ? reinit() : (onLoad && onLoad.call(this, res)) } })() e.onShow = (() => { return function (res) { !this.useCache && onShow && onShow.call(this, res) } })() e.onUnload = (() => { return function (res) { this.app.globalData = Object.assign(this.app.globalData || {}, { lastPage: this }) onUnload && onUnload.call(this, res) } })() return myPage.call(this, e) } [代码] 在需要监听的页面加个onBeforeBack方法,方法返回Promise化的wx.showModal [代码]onBeforeBack: function () { return wx.showModal({ title: '提示', content: '信息尚未保存,确定要返回吗?', confirmStay: !1 //结合content意思,点击确定按钮,是否留在原来页面,confirmStay默认false }) } [代码] 运行测试,Oj8K 是不是很简单,马上去试试水吧,效果图就不放了,静态图也看不出效果,动态图懒得弄,想看效果的自己运行代码片段吧 代码片段 https://developers.weixin.qq.com/s/hc2tyrmw79hg
2020-07-28 - 小程序app.globalData属性值改变时其它页面的引用响应更新
前提说明 小程序app.js的globalData中定义了userInfo属性,并且在首页和我的tab中引用了,当在其它页面更新userInfo后,在首页和我的中引用的userInfo未更新。 解决思路 利用发布-订阅的设计模式,app.js中的userInfo用Object.defineProperty实现数据劫持,当监听到userInfo值改变时,通知每个订阅者。在首页和我的页面调用app.js中的订阅方法,将更新数据的方法追加到userInfo的订阅者列表中。 实现代码 [代码]// app.js onLaunch: async function () { this.initObserve(); }, // 监听globalData中属性变化 initObserve() { const obj = this.globalData; const keys = ['userInfo']; keys.forEach(key => { let value = obj[key]; obj[`${key}SubscriberList`] = []; Object.defineProperty(obj, key, { configurable: true, enumerable: true, set(newValue) { obj[`${key}SubscriberList`].forEach(watch => { watch(newValue); }); value = newValue; }, get() { return value; } }); }); }, // 订阅globalData中某个属性变化 subscribe(key, watch) { watch(this.globalData[key]); this.globalData[`${key}SubscriberList`].push(watch); }, // 首页和我的page页 onLoad() { app.subscribe('userInfo', (userInfo) => { this.setData({ userInfo, }); }); }, [代码] 遇到的问题 小心Object.defineProperty中的set方法死循环导致栈溢出。在set用obj[key] = value时将会导致死循环,因为给属性赋值后,会再次调用set方法。解决的办法是利用闭包的原理,定义临时变量为obj[key],在set方法中对临时变量赋值。或者在obj中声明一个变量的副本,set中对变量副本赋值,get中返回变量副本。
2021-01-16 - 一个通用request的封装
小程序内置了[代码]wx.request[代码],用于向后端发送请求,我们先来看看它的文档: wx.request(OBJECT) 发起网络请求。使用前请先阅读说明。 OBJECT参数说明: 参数名 类型 必填 默认值 说明 最低版本 url String 是 - 开发者服务器接口地址 - data Object/String/ArrayBuffer 否 - 请求的参数 - header Object 否 - 设置请求的 header,header 中不能设置 Referer。 - method String 否 GET (需大写)有效值:OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, CONNECT - dataType String 否 json 如果设为json,会尝试对返回的数据做一次 JSON.parse - responseType String 否 text 设置响应的数据类型。合法值:text、arraybuffer 1.7.0 success Function 否 - 收到开发者服务成功返回的回调函数 - fail Function 否 - 接口调用失败的回调函数 - complete Function 否 - 接口调用结束的回调函数(调用成功、失败都会执行) - success返回参数说明: 参数 类型 说明 最低版本 data Object/String/ArrayBuffer 开发者服务器返回的数据 - statusCode Number 开发者服务器返回的 HTTP 状态码 - header Object 开发者服务器返回的 HTTP Response Header 1.2.0 这里我们主要看两点: 回调函数:success、fail、complete; success的返回参数:data、statusCode、header。 相对于通过入参传回调函数的方式,我更喜欢promise的链式,这是我期望的第一个点;success的返回参数,在实际开发过程中,我只关心data部分,这里可以做一下处理,这是第二点。 promisify 小程序默认支持promise,所以这一点改造还是很简单的: [代码]/** * promise请求 * 参数:参考wx.request * 返回值:[promise]res */ function requestP(options = {}) { const { success, fail, } = options; return new Promise((res, rej) => { wx.request(Object.assign( {}, options, { success: res, fail: rej, }, )); }); } [代码] 这样一来我们就可以使用这个函数来代替wx.request,并且愉快地使用promise链式: [代码]requestP({ url: '/api', data: { name: 'Jack' } }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); [代码] 注意,小程序的promise并没有实现finally,Promise.prototype.finally是undefined,所以complete不能用finally代替。 精简返回值 精简返回值也是很简单的事情,第一直觉是,当请求返回并丢给我一大堆数据时,我直接resolve我要的那一部分数据就好了嘛: [代码]return new Promise((res, rej) => { wx.request(Object.assign( {}, options, { success(r) { res(r.data); // 这里只取data }, fail: rej, }, )); }); [代码] but!这里需要注意,我们仅仅取data部分,这时候默认所有success都是成功的,其实不然,wx.request是一个基础的api,fail只发生在系统和网络层面的失败情况,比如网络丢包、域名解析失败等等,而类似404、500之类的接口状态,依旧是调用success,并体现在[代码]statusCode[代码]上。 从业务上讲,我只想处理json的内容,并对json当中的相关状态进行处理;如果一个接口返回的不是约定好的json,而是类似404、500之类的接口异常,我统一当成接口/网络错误来处理,就像jquery的ajax那样。 也就是说,如果我不对[代码]statusCode[代码]进行区分,那么包括404、500在内的所有请求结果都会走[代码]requestP().then[代码],而不是[代码]requestP().catch[代码]。这显然不是我们熟悉的使用方式。 于是我从jquery的ajax那里抄来了一段代码。。。 [代码]/** * 判断请求状态是否成功 * 参数:http状态码 * 返回值:[Boolen] */ function isHttpSuccess(status) { return status >= 200 && status < 300 || status === 304; } [代码] [代码]isHttpSuccess[代码]用来决定一个http状态码是否判为成功,于是结合[代码]requestP[代码],我们可以这么来用: [代码]return new Promise((res, rej) => { wx.request(Object.assign( {}, options, { success(r) { const isSuccess = isHttpSuccess(r.statusCode); if (isSuccess) { // 成功的请求状态 res(r.data); } else { rej({ msg: `网络错误:${r.statusCode}`, detail: r }); } }, fail: rej, }, )); }); [代码] 这样我们就可以直接resolve返回结果中的data,而对于非成功的http状态码,我们则直接reject一个自定义的error对象,这样就是我们所熟悉的ajax用法了。 登录 我们经常需要识别发起请求的当前用户,在web中这通常是通过请求中携带的cookie实现的,而且对于前端开发者是无感知的;小程序中没有cookie,所以需要主动地去补充相关信息。 首先要做的是:登录。 通过[代码]wx.login[代码]接口我们可以得到一个[代码]code[代码],调用后端登录接口将code传给后端,后端再用code去调用微信的登录接口,换取[代码]sessionKey[代码],最后生成一个[代码]sessionId[代码]返回给前端,这就完成了登录。 [图片] 具体参考微信官方文档:wx.login [代码]const apiUrl = 'https://jack-lo.github.io'; let sessionId = ''; /** * 登录 * 参数:undefined * 返回值:[promise]res */ function login() { return new Promise((res, rej) => { // 微信登录 wx.login({ success(r1) { if (r1.code) { // 获取sessionId requestP({ url: `${apiUrl}/api/login`, data: { code: r1.code, }, method: 'POST' }) .then((r2) => { if (r2.rcode === 0) { const { sessionId } = r2.data; // 保存sessionId sessionId = sessionId; res(r2); } else { rej({ msg: '获取sessionId失败', detail: r2 }); } }) .catch((err) => { rej(err); }); } else { rej({ msg: '获取code失败', detail: r1 }); } }, fail: rej, }); }); } [代码] 好的,我们做好了登录并且顺利获取到了sessionId,接下来是考虑怎么把sessionId通过请求带上去。 sessionId 为了将状态与数据区分开来,我们决定不通过data,而是通过header的方式来携带sessionId,我们对原本的requestP稍稍进行修改,使得它每次调用都自动在header里携带sessionId: [代码]function requestP(options = {}) { const { success, fail, } = options; // 统一注入约定的header let header = Object.assign({ sessionId: sessionId }, options.header); return new Promise((res, rej) => { ... }); } [代码] 好的,现在请求会自动带上sessionId了; 但是,革命尚未完成: 我们什么时候去登录呢?或者说,我们什么时候去获取sessionId? 假如还没登录就发起请求了怎么办呢? 登录过期了怎么办呢? 我设想有这样一个逻辑: 当我发起一个请求的时候,如果这个请求不需要sessionId,则直接发出; 如果这个请求需要携带sessionId,就去检查现在是否有sessionId,有的话直接携带,发起请求; 如果没有,自动去走登录的流程,登录成功,拿到sessionId,再去发送这个请求; 如果有,但是最后请求返回结果是sessionId过期了,那么程序自动走登录的流程,然后再发起一遍。 其实上面的那么多逻辑,中心思想只有一个:都是为了拿到sessionId! 我们需要对请求做一层更高级的封装。 首先我们需要一个函数专门去获取sessionId,它将解决上面提到的2、3点: [代码]/** * 获取sessionId * 参数:undefined * 返回值:[promise]sessionId */ function getSessionId() { return new Promise((res, rej) => { // 本地sessionId缺失,重新登录 if (!sessionId) { login() .then((r1) => { res(r1.data.sessionId); }) .catch(rej); } } else { res(sessionId); } }); } [代码] 好的,接下来我们解决第1、4点,我们先假定:sessionId过期的时候,接口会返回[代码]code=401[代码]。 整合了getSessionId,得到一个更高级的request方法: [代码]/** * ajax高级封装 * 参数:[Object]option = {},参考wx.request; * [Boolen]keepLogin = false * 返回值:[promise]res */ function request(options = {}, keepLogin = true) { if (keepLogin) { return new Promise((res, rej) => { getSessionId() .then((r1) => { // 获取sessionId成功之后,发起请求 requestP(options) .then((r2) => { if (r2.rcode === 401) { // 登录状态无效,则重新走一遍登录流程 // 销毁本地已失效的sessionId sessionId = ''; getSessionId() .then((r3) => { requestP(options) .then(res) .catch(rej); }); } else { res(r2); } }) .catch(rej); }) .catch(rej); }); } else { // 不需要sessionId,直接发起请求 return requestP(options); } } [代码] 留意req的第二参数keepLogin,是为了适配有些接口不需要sessionId,但因为我的业务里大部分接口都需要登录状态,所以我默认值为true。 这差不多就是我们封装request的最终形态了。 并发处理 这里其实我们还需要考虑一个问题,那就是并发。 试想一下,当我们的小程序刚打开的时候,假设页面会同时发出5个请求,而此时没有sessionId,那么,这5个请求按照上面的逻辑,都会先去调用login去登录,于是乎,我们就会发现,登录接口被同步调用了5次!并且后面的调用将导致前面的登录返回的sessionId过期~ 这bug是很严重的,理论上来说,登录我们只需要调用一次,然后一直到过期为止,我们都不需要再去登录一遍了。 ——那么也就是说,同一时间里的所有接口其实只需要登录一次就可以了。 ——也就是说,当有登录的请求发出的时候,其他那些也需要登录状态的接口,不需要再去走登录的流程,而是等待这次登录回来即可,他们共享一次登录操作就可以了! 解决这个问题,我们需要用到队列。 我们修改一下getSessionId这里的逻辑: [代码]const loginQueue = []; let isLoginning = false; /** * 获取sessionId * 参数:undefined * 返回值:[promise]sessionId */ function getSessionId() { return new Promise((res, rej) => { // 本地sessionId缺失,重新登录 if (!sessionId) { loginQueue.push({ res, rej }); if (!isLoginning) { isLoginning = true; login() .then((r1) => { isLoginning = false; while (loginQueue.length) { loginQueue.shift().res(r1); } }) .catch((err) => { isLoginning = false; while (loginQueue.length) { loginQueue.shift().rej(err); } }); } } else { res(sessionId); } }); } [代码] 使用了isLoginning这个变量来充当锁的角色,锁的目的就是当登录正在进行中的时候,告诉程序“我已经在登录了,你先把回调都加队列里去吧”,当登录结束之后,回来将锁解开,把回调全部执行并清空队列。 这样我们就解决了问题,同时提高了性能。 封装 在做完以上工作以后,我们都很清楚的封装结果就是[代码]request[代码],所以我们把request暴露出去就好了: [代码]function request() { ... } module.exports = request; [代码] 这般如此之后,我们使用起来就可以这样子: [代码]const request = require('request.js'); Page({ ready() { // 获取热门列表 request({ url: 'https://jack-lo.github.io/api/hotList', data: { page: 1 } }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); // 获取最新信息 request({ url: 'https://jack-lo.github.io/api/latestList', data: { page: 1 } }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); }, }); [代码] 是不是很方便,可以用promise的方式,又不必关心登录的问题。 然而可达鸭眉头一皱,发现事情并不简单,一个接口有可能在多个地方被多次调用,每次我们都去手写这么一串[代码]url[代码]参数,并不那么方便,有时候还不好找,并且容易出错。 如果能有个地方专门记录这些url就好了;如果每次调用接口,都能像调用一个函数那么简单就好了。 基于这个想法,我们还可以再做一层封装,我们可以把所有的后端接口,都封装成一个方法,调用接口就相对应调用这个方法: [代码]const apiUrl = 'https://jack-lo.github.io'; const req = { // 获取热门列表 getHotList(data) { const url = `${apiUrl}/api/hotList` return request({ url, data }); }, // 获取最新列表 getLatestList(data) { const url = `${apiUrl}/api/latestList` return request({ url, data }); } } module.exports = req; // 注意这里暴露的已经不是request,而是req [代码] 那么我们的调用方式就变成了: [代码]const req = require('request.js'); Page({ ready() { // 获取热门列表 req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); // 获取最新信息 req.getLatestList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); } }); [代码] 这样一来就方便了很多,而且有一个很大的好处,那就是当某个接口的地址需要统一修改的时候,我们只需要对[代码]request.js[代码]进行修改,其他调用的地方都不需要动了。 错误信息的提炼 最后的最后,我们再补充一个可轻可重的点,那就是错误信息的提炼。 当我们在封装这么一个[代码]req[代码]对象的时候,我们的promise曾经reject过很多的错误信息,这些错误信息有可能来自: [代码]wx.request[代码]的fail; 不符合[代码]isHttpSuccess[代码]的网络错误; getSessionId失败; … 等等的一切可能。 这就导致了我们在提炼错误信息的时候陷入困境,到底catch到的会是哪种[代码]error[代码]对象? 这么看你可能不觉得有问题,我们来看看下面的例子: [代码]req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); [代码] 假如上面的例子中,我想要的不仅仅是[代码]console.log(err)[代码],而是想将对应的错误信息弹窗出来,我应该怎么做? 我们只能将所有可能出现的错误都检查一遍: [代码]req.getHotList({ page: 1 }) .then((res) => { if (res.code !== 0) { // 后端接口报错格式 wx.showModal({ content: res.msg }); } }) .catch((err) => { let msg = '未知错误'; // 文本信息直接使用 if (typeof err === 'string') { msg = err; } // 小程序接口报错 if (err.errMsg) { msg = err.errMsg; } // 自定义接口的报错,比如网络错误 if (err.detail && err.detail.errMsg) { msg = err.detail.errMsg; } // 未知错误 wx.showModal({ content: msg }); }); [代码] 这就有点尴尬了,提炼错误信息的代码量都比业务还多几倍,而且还是每个接口调用都要写一遍~ 为了解决这个问题,我们需要封装一个方法来专门做提炼的工作: [代码]/** * 提炼错误信息 * 参数:err * 返回值:[string]errMsg */ function errPicker(err) { if (typeof err === 'string') { return err; } return err.msg || err.errMsg || (err.detail && err.detail.errMsg) || '未知错误'; } [代码] 那么过程会变成: [代码]req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { const msg = req.errPicker(err); // 未知错误 wx.showModal({ content: msg }); }); [代码] 好吧,我们再偷懒一下,把wx.showModal也省去了: [代码]/** * 错误弹窗 */ function showErr(err) { const msg = errPicker(err); console.log(err); wx.showModal({ showCancel: false, content: msg }); } [代码] 最后就变成了: [代码]req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch(req.showErr); [代码] 至此,一个简单的wx.request封装过程便完成了,封装过的[代码]req[代码]比起原来,使用上更加方便,扩展性和可维护性也更好。 结尾 以上内容其实是简化版的[代码]mp-req[代码],介绍了[代码]mp-req[代码]这一工具的实现初衷以及思路,使用[代码]mp-req[代码]来管理接口会更加的便捷,同时[代码]mp-req[代码]也提供了更加丰富的功能,比如插件机制、接口的缓存,以及接口分类等,欢迎大家关注mp-req了解更多内容。 以上最终代码可以在这里获取:req.js。
2020-08-04 - 微信小程序之分享功能
今天分享一下在小程序开发中,关于分享功能的常见的3种实现方式: 1)入口 a.小程序右上角自带的分享功能: 如果在当前页面调用wx.hideShareMenu()方法,那么右上角的分享功能将被隐藏 调用wx.showShareMenu()方法,可以显示该功能。 下面是开发文档中的注意事项和示例代码: 注意事项: "shareAppMessage"表示“发送给朋友”按钮,"shareTimeline"表示“分享到朋友圈”按钮显示“分享到朋友圈”按钮时必须同时显示“发送给朋友”按钮,显示“发送给朋友”按钮时则允许不显示“分享到朋友圈”按钮示例代码: wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] }) b.自定义分享按钮,页面内发起转发: 通过给 button 组件设置属性 open-type="share",可以在用户点击按钮后触发 Page.onShareAppMessage 事件分享 c.生成带小程序码的海报 通过官方提供的接口可生成带参数的小程序码 https://api.weixin.qq.com/cgi-bin/wxaapp/createwxaqrcode?access_token=ACCESS_TOKEN 请求参数[图片] 以上3种方式均可实现分享功能 2)自定义转发分享内容: a. 转发给好友/群 通过onShareAppMessage方法设置官方文档对该方法的介绍: [图片] 示例代码: Page({ onShareAppMessage: function (res) { if (res.from === 'button') { // 来自页面内转发按钮 console.log(res.target) } return { title: '自定义转发标题', path: '/page/user?id=123' } } }) 如果开发人员在onShareAppMessage(options)不进行任何处理,那么微信将 会有一个默认的数据转发出去,title为当前小程序名称,path为当前页面的路径, imageUrl为当前页面的截图。 b. 转发到朋友圈 通过onShareTimeline()方法设置官方介绍文档: [图片] 对于分享到朋友圈,有些要注意的地方,比如现在只支持安卓系统 且分享到朋友圈的是单页模式,有以下限制: 页面无登录态,与登录相关的接口,如 [代码]wx.login[代码] 均不可用;云开发资源需开启未登录访问方可在单页模式下使用,详见未登录模式。不允许跳转到其它页面,包括任何跳小程序页面、跳其它小程序、跳微信原生页面不允许横屏使用若页面包含 tabBar,tabBar 不会渲染,包括自定义 tabBar本地存储与小程序普通模式不共用详情见官方参考文档 有更多关于实现微信小程序分享的骚操作欢迎留言分享交流
2020-12-17 - 前端代码优化初入理解:封装和组件化的重要性(上)
这篇文章纯属个人想法,如果有错误还望各位大佬多多指教! 从事前端工作3年左右,一直致力于如何把页面做的更好看,不注重代码的性能,2020年末在关注的几个前端公众号文章里发现,前端不只是页面的美观,还有给予用户的性能体检,在文章里体会到了将自己写的20行代码,去掉了废话只用4,5行就完成同样的功能的新奇。 所以励志在21年里我将会在代码的优化和复用性,进行深入的学习。 有一篇文章是说我们常用的if-else,这篇文章将初级前端程序员的现状说很明确,只知道if--else,却不知道 if return 【举例】:判断是否是带有刘海屏幕的设备 if (app.globalData.isIphoneX) { that.setData({ isiphoneX: 'page_title_X' }) }else{ that.setData({ isiphoneX: '' }) } 这就是一开始我写出来的代码,功能非常简单却用了9行代码。阅读文章之后,改过的代码 if(app.globalData.isIphoneX) this.setData({ isiphoneX:'page_title_X' }) 为什么else里面代码是无用的呢,我仔细想了一下判断机型一次就够了,不可能出现这个页面第二次onload之后这个手机型号改变了,所以else就是一段废话,去掉这个废话就用了一行代码,我自己写完之后也是痛骂之前的自己,就是一个蠢*,虽然这点并不会项目整体带来太多的改善,但是思维的改变可以让你更好的去改善及优化代码 下面这个是我阅读的文章链接,看完之后给我的触动很大,希望也对个别的前端人有帮助,(也是真的觉得提升写代码的水平会涨工资) https://mp.weixin.qq.com/s/A9OOawQakNmgvzF_CJY8bw 上面说的也就是代码优化路上的冰山一角,优化代码的方式很多很多,接下来就说说封装吧 封装:个人理解,将一些复用性高的代码进行封装,可以有效的减少代码冗余,方便维护升级。 我们在开发小程序时候,用到最多的wx.request,随着项目越来越大,请求也就越来越多,接口升级,请求地址发生了改变,这个接口在很多页面都用到了,那么这个代码的修改维护工作量很大,很浪费时间,毫无意义的重复工作。 所以我的做法将小程序会用到的所有请求地址,做了一个全局变量api,统一到一个js文件里去维护,请求时候通过引入js,api.请求地址去引用这个变量 //api.js var url = 'https://requestUrl?param'(地址非真实地址) api={ index:{ banner:url+'getBannerList' } } module.exports = api; //index.js const api = require("api.js"); wx.request({ url: api.index.banner ... }) 如果再有请求地址的改变,我们只需要改动api.js里的地址就可以了,后期项目有了一定的规模,在有类似的改用,你就会发现这样的代码维护起来简直爽上天。 这是对请求地址的封装。 当小程序正式上线,为了能准确地捕获请求错误的原因,我们需要在请求时候将fail的原因反馈给我们管理员,上传到小程序的实时指日上,并且给用户一个友好的提醒,所以wx.request如下 wx.request({ url: api.index.banner, success: function (resd) { if (resd.data.status != 1) { //请求出错,将原因反馈给开发人员 。。。 //给用户提示 wx.showToast({ title: '系统出现点小问题', mask:true, duration:3000, icon:'none' }) return false; } //请求成功 }, fail:function(e){ //请求出错,将原因反馈给开发人员 。。。 //给用户提示 wx.showToast({ title: '请求失败,请检查网络', mask: true, duration: 3000, icon: 'none' }) } }) 上面代码确实可以实现给用户友好提示和给管理员提醒两个功能,但是缺点是以后每个接口都要去写一次相同的提示代码,这个重复工作也是毫无意义的,而且冗余的代码会增加小程序代码的体积。所以也是用封装的思路将wx.request()封装成一个公共函数 //api.js /** * 封装wx.request() * @param {请求页面主要用来提示管理员那个页面出现了问题} page * @param {请求地址} url * @param {请求参数} data * @param {请求头} header */ function request(page, url, data, header) { //用Promise封装 return new Promise(function (resolve, reject) { wx.request({ url: url, method:"POST", data: data != '' ? JSON.stringify(data) : data,//是否需要参数 header: header != '' ? JSON.stringify(header) : header,//是否需要header success: (res) => { if (res.data.status == 1) { //将获取的数据回传 resolve(res.data.data); } else { //系统自定义一场掉用log提醒,记录 log.datafun(page, url, res.data.errMsg, 'unionid:' + unionid!=null ? unionid : '').then(function () { wx.showToast({ title: '系统出现点小问题', mask: true, duration: 3000, icon: 'none' }) }) reject(res) } }, fail: (e) => { log.datafun(page, url, e.errMsg, 'unionid:' + unionid != null ? unionid : '').then(function () { wx.showToast({ title: '请检查您的网络链接', mask: true, duration: 3000, icon: 'none' }) }) reject(e) } }) }) }; module.exports = request; log.datafun是我将管理员提示和小程序实时日志进行了封装处理,上面就是我将wx.request进行了封装,很简单,满足我们公司现在所有业务接口,同时也满足了减少代码量的要求,更加便于维护 调用方法 const api = require("api.js"); api.request('请求页面',api.index.banner).then(function(res){ //请求成功后页面逻辑 }).catch(function(e){ //请求失败后页面逻辑 }) 上方就是我对封装的一个理解,可能理解的有些浅薄,如果有大佬看到后能做出指点或者意见。 此文章只代表我个人的理解,也是为了记录一下我成长过程,如果有幸能帮助到您,希望您给点个赞,欢迎大家留言来批 下篇文章会记录一下我对组件的理解,感谢阅读
2021-02-18 - Painter 一款轻量级的小程序海报生成组件
生成海报相信大家有的人都做过,但是canvas绘图的坑太多。大家可以试试这个组件。然后附上楼下大哥做的可视化拖拽生成painter代码的工具:链接地址https://developers.weixin.qq.com/community/develop/article/doc/000e222d9bcc305c5739c718d56813
2019-09-27 - 吐槽一下weui小程序扩展组件库
最近尝试着用了下weui小程序的组件库,发现几个问题今天发出来跟大家探讨一下。 一:官方文档介绍npm安装方法不够详细。 按照官方文档npm install去下载weui的时候,能下载下来,但是构建npm的时候始终失败,你绞尽脑汁也想不到为什么会失败呢,其实是你的项目里面没有package.json文件。你在install之前需要先初始化一下npm init 然后把相关的信息补全后,会生成这个文件。这个问题可以说是对npm了解不够深刻,但是我觉得官方文档应该在详细一下会更好。 二:构建npm的问题 很想知道微信开发者工具里面的“构建npm”功能和项目设置里面的勾选“使用npn模块“是什么意思。构建npm这次我大概知道了,是从node_modules文件夹里面重新生成出来一套可以供小程序引用的一个文件夹如图: [图片] 看到了吗,构建npm之后会多生成一个npm结尾的文件夹,然后项目里面import文件路径是从这个miniprogram_npm文件里面引用的。 我想说好low啊,为鸡毛已经npm下载好的模块不能直接使用呢,还需要在构建出来一套,为什么不能向react项目那样,npm安装好的包可以直接在项目import,微信小程序这点儿做的感觉多此一举。构建npm这个功能是不是就是从npm下载的包,需要从新生成一套可以项目使用的文件?还有其它的含义吗?目前不清楚。 还有就是项目配置信息里面的 使用npn模块勾选项,请问这个是什么意思,看官方文档没看明白,大致理解的是如果把自己的项目打包上传到npm管理的话,好像是需要勾选这个npm模块选项的。不知道理解的对不对。总体感觉官方文档写的不够详细。很多都是需要自己花费很大时间琢磨的事情。不知道大家有没有同感。 三:weui小程序组件库基础组件太少了 weui小程序扩展组件库,能够满足的场景我感觉是非常少的。你看weui组件库组件就可以看到,他提供的是一套包含布局及页面级别的组件。意思是说,如果你要创建的form表单跟weui提供的form页面组件风格一致的话,你就可以使用。如果不一样的话是没有办法用form组件的,呵呵,试问大家需求千变万化,怎么可能会向weui提供的form组件页面那样高度一致? [图片] [图片] 看图,我理解的是只有你得页面结构及风格跟weui组件一样的时候你才能用weui组件库组件。有没有感觉到实用空间非常小?weui组件库是这样使用的吗?还是我理解的有问题,因为从weui文档上能看到的东西也就这么多。 其实我觉得,weui组件库是在weui样式库上继承过来的,其实它应该更侧重样式这一块儿内容,通过weui样式及微信小程序本身自带的基础组件,我觉得应该会很完美吧。weui扩展的组件库感觉不能够完全撑起复杂多变的业务需求。 不知道以上内容是不是我对weui组件库了解的还不够深,毕竟是初次使用。还是大家跟我的看法一样,还请大家帮忙指点一下weui组件库具体该怎么组合使用。我觉得应该基础组件在丰富些就好了,可以让用户自由组装。类似 蚂蚁金服的ant design 组件库。 欢迎讨论。多多指教
2020-05-11