- 代金券api 返回活动未开始或已结束 ?
["stock_name"] => string(6) "你好" ["comment"] => string(6) "你好" ["belong_merchant"] => string(10) "*******" ["available_begin_time"] => string(29) "2021-03-16T14:08:48.120+08:00" ["available_end_time"] => string(29) "2021-04-15T14:07:48.120+08:00" ["stock_use_rule"] => array(6) { ["max_coupons_per_user"] => int(10) ["max_coupons"] => int(10) ["max_amount"] => int(10) ["max_amount_by_day"] => int(1000) ["natural_person_limit"] => bool(true) ["prevent_api_abuse"] => bool(true) } ["coupon_use_rule"] => array(2) { ["fixed_normal_coupon"] => array(2) { ["coupon_amount"] => int(1) ["transaction_minimum"] => int(1) } ["available_merchants"] => array(1) { [0] => string(10) "*******" } } ["no_cash"] => bool(false) ["stock_type"] => string(6) "NORMAL" ["out_request_no"] => string(24) "161587486816158748686725"
2021-03-16 - 路由的封装
小程序提供了路由功能来实现页面跳转,但是在使用的过程中我们还是发现有些不方便的地方,通过封装,我们可以实现诸如路由管理、简化api等功能。 页面的跳转存在哪些问题呢? 与接口的调用一样面临url的管理问题; 传递参数的方式不太友好,只能拼装url; 参数类型单一,只支持string。 alias 第一个问题很好解决,我们做一个集中管理,比如新建一个[代码]router/routes.js[代码]文件来实现alias: [代码]// routes.js module.exports = { // 主页 home: '/pages/index/index', // 个人中心 uc: '/pages/user_center/index', }; [代码] 然后使用的时候变成这样: [代码]const routes = require('../../router/routes.js'); Page({ onReady() { wx.navigateTo({ url: routes.uc, }); }, }); [代码] query 第二个问题,我们先来看个例子,假如我们跳转[代码]pages/user_center/index[代码]页面的同时还要传[代码]userId[代码]过去,正常情况下是这么来操作的: [代码]const routes = require('../../router/routes.js'); Page({ onReady() { const userId = '123456'; wx.navigateTo({ url: `${routes.uc}?userId=${userId}`, }); }, }); [代码] 这样确实不好看,我能不能把参数部分单独拿出来,不用拼接到url上呢? 可以,我们试着实现一个[代码]navigateTo[代码]函数: [代码]const routes = require('../../router/routes.js'); function navigateTo({ url, query }) { const queryStr = Object.keys(query).map(k => `${k}=${query[k]}`).join('&'); wx.navigateTo({ url: `${url}?${queryStr}`, }); } Page({ onReady() { const userId = '123456'; navigateTo({ url: routes.uc, query: { userId, }, }); }, }); [代码] 嗯,这样貌似舒服一点。 参数保真 第三个问题的情况是,当我们传递的参数argument不是[代码]string[代码],而是[代码]number[代码]或者[代码]boolean[代码]时,也只能在下个页面得到一个[代码]string[代码]值: [代码]// pages/index/index.js Page({ onReady() { navigateTo({ url: routes.uc, query: { isActive: true, }, }); }, }); // pages/user_center/index.js Page({ onLoad(options) { console.log(options.isActive); // => "true" console.log(typeof options.isActive); // => "string" console.log(options.isActive === true); // => false }, }); [代码] 上面这种情况想必很多人都遇到过,而且感到很抓狂,本来就想传递一个boolean,结果不管传什么都会变成string。 有什么办法可以让数据变成字符串之后,还能还原成原来的类型? 好熟悉,这不就是json吗?我们把要传的数据转成json字符串([代码]JSON.stringify[代码]),然后在下个页面把它转回json数据([代码]JSON.parse[代码])不就好了嘛! 我们试着修改原来的[代码]navigateTo[代码]: [代码]const routes = require('../../router/routes.js'); function navigateTo({ url, data }) { const dataStr = JSON.stringify(data); wx.navigateTo({ url: `${url}?jsonStr=${dataStr}`, }); } Page({ onReady() { navigateTo({ url: routes.uc, data: { isActive: true, }, }); }, }); [代码] 这样我们在页面中接受json字符串并转换它: [代码]// pages/user_center/index.js Page({ onLoad(options) { const json = JSON.parse(options.jsonStr); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] 这里其实隐藏了一个问题,那就是url的转义,假如json字符串中包含了类似[代码]?[代码]、[代码]&[代码]之类的符号,可能导致我们参数解析出错,所以我们要把json字符串encode一下: [代码]function navigateTo({ url, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } // pages/user_center/index.js Page({ onLoad(options) { const json = JSON.parse(decodeURIComponent(options.encodedData)); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] 这样使用起来不方便,我们封装一下,新建文件[代码]router/index.js[代码]: [代码]const routes = require('./routes.js'); function navigateTo({ url, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } function extract(options) { return JSON.parse(decodeURIComponent(options.encodedData)); } module.exports = { routes, navigateTo, extract, }; [代码] 页面中我们这样来使用: [代码]const router = require('../../router/index.js'); // page home Page({ onLoad(options) { router.navigateTo({ url: router.routes.uc, data: { isActive: true, }, }); }, }); // page uc Page({ onLoad(options) { const json = router.extract(options); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] route name 这样貌似还不错,但是[代码]router.navigateTo[代码]不太好记,[代码]router.routes.uc[代码]有点冗长,我们考虑把[代码]navigateTo[代码]换成简单的[代码]push[代码],至于路由,我们可以使用[代码]name[代码]的方式来替换原来[代码]url[代码]参数: [代码]const routes = require('./routes.js'); function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const url = routes[name]; wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } function extract(options) { return JSON.parse(decodeURIComponent(options.encodedData)); } module.exports = { push, extract, }; [代码] 在页面中使用: [代码]const router = require('../../router/index.js'); Page({ onLoad(options) { router.push({ name: 'uc', data: { isActive: true, }, }); }, }); [代码] navigateTo or switchTab 页面跳转除了navigateTo之外还有switchTab,我们是不是可以把这个差异抹掉?答案是肯定的,如果我们在配置routes的时候就已经指定是普通页面还是tab页面,那么程序完全可以切换到对应的跳转方式。 我们修改一下[代码]router/routes.js[代码],假设home是一个tab页面: [代码]module.exports = { // 主页 home: { type: 'tab', path: '/pages/index/index', }, uc: { path: '/pages/a/index', }, }; [代码] 然后修改[代码]router/index.js[代码]中[代码]push[代码]的实现: [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; if (route.type === 'tab') { wx.switchTab({ url: `${route.path}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${route.path}?encodedData=${dataStr}`, }); } [代码] 搞定,这样我们一个[代码]router.push[代码]就能自动切换两种跳转方式了,而且之后一旦页面类型有变动,我们也只需要修改[代码]route[代码]的定义就可以了。 直接寻址 alias用着很不错,但是有一点挺麻烦得就是每新建一个页面都要写一个alias,即使没有别名的需要,我们是不是可以处理一下,如果在alias没命中,那就直接把name转化成url?这也是阔以的。 [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; const url = route ? route.path : name; if (route.type === 'tab') { wx.switchTab({ url: `${url}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } [代码] 在页面中使用: [代码]Page({ onLoad(options) { router.push({ name: 'pages/user_center/a/index', data: { isActive: true, }, }); }, }); [代码] 注意,为了方便维护,我们规定了每个页面都必须存放在一个特定的文件夹,一个文件夹的当前路径下只能存在一个index页面,比如[代码]pages/index[代码]下面会存放[代码]pages/index/index.js[代码]、[代码]pages/index/index.wxml[代码]、[代码]pages/index/index.wxss[代码]、[代码]pages/index/index.json[代码],这时候你就不能继续在这个文件夹根路径存放另外一个页面,而必须是新建一个文件夹来存放,比如[代码]pages/index/pageB/index.js[代码]、[代码]pages/index/pageB/index.wxml[代码]、[代码]pages/index/pageB/index.wxss[代码]、[代码]pages/index/pageB/index.json[代码]。 这样是能实现功能,但是这个name怎么看都跟alias风格差太多,我们试着定义一套转化规则,让直接寻址的name与alias风格统一一些,[代码]pages[代码]和[代码]index[代码]其实我们可以省略掉,[代码]/[代码]我们可以用[代码].[代码]来替换,那么原来的name就变成了[代码]user_center.a[代码]: [代码]Page({ onLoad(options) { router.push({ name: 'user_center.a', data: { isActive: true, }, }); }, }); [代码] 我们再来改进[代码]router/index.js[代码]中[代码]push[代码]的实现: [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; const url = route ? route.path : `pages/${name.replace(/\./g, '/')}/index`; if (route.type === 'tab') { wx.switchTab({ url: `${url}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } [代码] 这样一来,由于支持直接寻址,跳转home和uc还可以写成这样: [代码]router.push({ name: 'index', // => /pages/index/index }); router.push({ name: 'user_center', // => /pages/user_center/index }); [代码] 这样一来,除了一些tab页面以及特定的路由需要写alias之外,我们也不需要新增一个页面就写一条alias这么麻烦了。 其他 除了上面介绍的navigateTo和switchTab外,其实还有[代码]wx.redirectTo[代码]、[代码]wx.navigateBack[代码]以及[代码]wx.reLaunch[代码]等,我们也可以做一层封装,过程雷同,所以我们就不再一个个介绍,这里贴一下最终简化后的api以及原生api的映射关系: [代码]router.push => wx.navigateTo router.replace => wx.redirectTo router.pop => wx.navigateBack router.relaunch => wx.reLaunch [代码] 最终实现已经在发布在github上,感兴趣的朋友可以移步了解:mp-router。
2019-04-26 - 如何实现一个自定义导航栏
自定义导航栏在刚出的时候已经有很多实现方案了,但是还有大哥在问,那这里再贴下代码及原理: 首先在App.js的 onLaunch中获取当前手机机型头部状态栏的高度,单位为px,存在内存中,操作如下: [代码]onLaunch() { wx.getSystemInfo({ success: (res) => { this.globalData.statusBarHeight = res.statusBarHeight this.globalData.titleBarHeight = wx.getMenuButtonBoundingClientRect().bottom + wx.getMenuButtonBoundingClientRect().top - (res.statusBarHeight * 2) }, failure() { this.globalData.statusBarHeight = 0 this.globalData.titleBarHeight = 0 } }) } [代码] 然后需要在目录下新建个components文件夹,里面存放此次需要演示的文件 navigateTitle WXML 文件如下: [代码]<view class="navigate-container"> <view style="height:{{statusBarHeight}}px"></view> <view class="navigate-bar" style="height:{{titleBarHeight}}px"> <view class="navigate-icon"> <navigator class="navigator-back" open-type="navigateBack" wx:if="{{!isShowHome}}" /> <navigator class="navigator-home" open-type="switchTab" url="/pages/index/index" wx:else /> </view> <view class="navigate-title">{{title}}</view> <view class="navigate-icon"></view> </view> </view> <view class="navigate-line" style="height: {{statusBarHeight + titleBarHeight}}px; width: 100%;"></view> [代码] WXSS文件如下: [代码].navigate-container { position: fixed; top: 0; width: 100%; z-index: 9999; background: #FFF; } .navigate-bar { width: 100%; display: flex; justify-content: space-around; } .navigate-icon { width: 100rpx; height: 100rpx; display: flex; justify-content: space-around; } .navigate-title { width: 550rpx; text-align: center; line-height: 100rpx; font-size: 34rpx; color: #3c3c3c; font-weight: bold; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; } /*箭头部分*/ .navigator-back { width: 36rpx; height: 36rpx; align-self: center; } .navigator-back:after { content: ''; display: block; width: 22rpx; height: 22rpx; border-right: 4rpx solid #000; border-top: 4rpx solid #000; transform: rotate(225deg); } .navigator-home { width: 56rpx; height: 56rpx; background: url(https://qiniu-image.qtshe.com/20190301home.png) no-repeat center center; background-size: 100% 100%; align-self: center; } [代码] JS如下: [代码]var app = getApp() Component({ data: { statusBarHeight: '', titleBarHeight: '', isShowHome: false }, properties: { //属性值可以在组件使用时指定 title: { type: String, value: '青团公益' } }, pageLifetimes: { // 组件所在页面的生命周期函数 show() { let pageContext = getCurrentPages() if (pageContext.length > 1) { this.setData({ isShowHome: false }) } else { this.setData({ isShowHome: true }) } } }, attached() { this.setData({ statusBarHeight: app.globalData.statusBarHeight, titleBarHeight: app.globalData.titleBarHeight }) }, methods: {} }) [代码] JSON如下: [代码]{ "component": true } [代码] 如何引用? 需要引用的页面JSON里配置: [代码]"navigationStyle": "custom", "usingComponents": { "navigate-title": "/pages/components/navigateTitle/index" } [代码] WXML [代码]<navigate-title title="青团社" /> [代码] 按上面步骤操作即可实现一个自定义的导航栏。 如何实现通栏的效果默认透明以及滚动更换title为白色背景,如下图所示: [图片] [图片] [图片] [图片] 最后代码片段如下: https://developers.weixin.qq.com/s/wi6Pglmv7s8P。 以下为收集到的社区老哥们的分享: @Yunior: 小程序顶部自定义导航组件实现原理及坑分享 @志军: 微信小程序自定义导航栏组件(完美适配所有手机),可自定义实现任何你想要的功能 @✨o0o有脾气的酸奶💤 [有点炫]自定义navigate+分包+自定义tabbar @安晓苏 分享一个自适应的自定义导航栏组件
2020-03-10 - 那些被忽略的盒子模型小知识
那些被忽略的盒子模型小知识 本文是笔者在学习CSS时的一些小白总结 我们知道的盒子模型主要由4个区域组成,分别是内容区域(content),内边距区域(padding),边框区域(border)和外边距区域(margin)。 对于不了解盒子模型的朋友可以移步到这里了解一下。 [图片] Content(内容) 1. 替换元素 替换元素(replaced element),顾名思义就是内容可以被替换的元素。 我们通常会把一些特殊意义的文本替换成图片,比如一个网站的logo。 [图片] 我们会在页面上看到的不是h1标签显示的”Google“文字,而是谷歌logo的图片。使用了content的元素的内容在html标签中是不存在的。这样做就有个好处,当爬虫来访问我们的网站,爬虫可以知道我们这个主站的h1标题是”Google“而不是一个img标签,且在视觉上给用户更好的体验。 2. 伪元素::before和::after [图片] 为了实现上面显示价格,之前写react代码时候会经常这么写,感觉在逻辑上写了好多关联性不大的文本。其实可以利用[代码]::before[代码]和[代码]::after[代码]两个伪元素,把这些与逻辑不相关的写在css里,react dom则专注于数据的表现。 [代码]<div className="price-panel"> <span className="price-panel__price"> ¥ {(totalPrice / 100).toFixed(1)} </span> <span className="price-panel__discount-price"> 已省¥ {(totalDiscountPrice / 100).toFixed(1)} </span> <span className="price-panel__discount"> ( {(discount / 10).toFixed(1)} 折) </span> </div> [代码] 使用了伪元素后的react代码显然更加清晰表示数据。 HTML: [代码]<div className="price-panel"> <span className="price-panel__price"> {(totalPrice / 100).toFixed(1)} </span> <span className="price-panel__discount-price"> {(totalDiscountPrice / 100).toFixed(1)} </span> <span className="price-panel__discount"> {(discount / 10).toFixed(1)} </span> </div> [代码] SCSS: [代码].price-panel { &__price { &::before { content: '¥'; } } &__discount-price { &::before { content: '已省¥'; } } &__discount { &::before { content: '('; } &::after { content: '折)'; } } } [代码] 我们还能使用伪元素帮助实现一些本来需要多个div实现的样式,比如下面这个对话框。 [图片] HTML: [代码]<div class="dialog">Hi,I’m a bubble dialog. Can you see me?</div> [代码] CSS: [代码].dialog { background: #f0f; padding: 10px; border-radius: 10px; color: white; max-width: 250px; position: relative; overflow: visible; } .dialog::after { position: absolute; content: ''; display: inline-block; border-width: 5px 10px; border-style: solid; border-color: transparent transparent #f0f #f0f; width: 0; height: 0; right: -20px; } [代码] Padding(内边距) padding的百分比值是非常有用的。需要注意的padding的百分比值,无论是水平方向还是垂直方向都是相对于父级元素的宽度进行计算的。 如果需要弄一张16:9的等比缩放图片,可以利用padding的这个特性,设置一个[代码]padding-top[代码]或者[代码]padding-bottom[代码]为56.25%即可(100\16*9) [图片] [图片] Margin(外边距) 1. margin合并 块级元素的[代码]margin-top[代码]和[代码]margin-bottom[代码]有时候会合并为单个margin,这种现象叫margin合并。 margin合并发生两个重要元素 必须是块级元素 只发生在垂直方向。 margin合并的场景 1.1 相邻兄弟元素 [图片] 1.2 父级和第一个/最后一个子元素 在实际开发中,父子margin合并很有可能会带给我们麻烦。 如下图所示,div表现出和我们预想不一致的结果。 [图片] 那么怎么才能防止这种父子margin合并导致的和预想不一致问题呢? 解决方法如下(这里直接复制了张鑫旭老师书籍《CSS世界》的原话。): (1)对于margin-top合并(满足一个即可): 父元素设置为BFC 设置[代码]border-top[代码]的值(亲测transparent也可以的) 设置[代码]padding-top[代码]的值 父元素和第一个子元素之间添加内联元素 (2)对于margin-bottom合并(满足一个即可): 父元素设置为BFC 设置[代码]border-bottom[代码](transparent也可以的) 设置[代码]padding-bottom[代码] 父元素和最后一个子元素之间添加一个内联元素 父元素设置[代码]height[代码]、[代码]min-height[代码]或者[代码]max-height[代码] 1.3 空块级元素的margin合并 [图片] 2. margin auto 每当说到[代码]margin:auto[代码],我的第一反应是居中。但这个只是一个浅层应用的表象。 接下来我们去一起看看这个[代码]margin:auto[代码]究竟是‘何方神圣’。 [代码]margin:auto[代码]的填充规则如下: 如果一侧定值,一侧auto,则auto为剩余空间大小。注意auto并不是0的意思。 如果两侧都是auto,则平分剩余的空间 我会疑惑为什么我设置了[代码]margin: auto[代码],却在垂直方向上没有居中。 [图片] 这里《css世界》中给出的答案让人非常容易理解。假如把.son元素的height去掉,.son的高度会自动变成父元素的200px,显然不会,所以无法触发margin: auto。同理,如果把width为200px去掉,确实是会和父元素一样宽。 那么如何让垂直居中呢? 子元素使用绝对定位后设置[代码]margin: auto[代码]即可 [图片] Border(边框) 用border绘制三角形 我们可以利用border color为透明来绘制一些图形,比如三角形 [图片] 注意[代码]border-color[代码]这个属性。 [代码]/* border-color: color; 单值语法 */ border-color: red; /* border-color: vertical horizontal; 双值语法*/ border-color: red #f015ca; /* border-color: top horizontal bottom; 三值语法 */ border-color: red yellow green; /* border-color: top right bottom left; 四值语法 */ border-color: red yellow green blue; [代码] 当然,我们绘制三角形不限于这种等腰三角。 [图片] 这里绘制了一个底边分别是60px和160px的直角三角形。 参考 文章主要参考了张鑫旭老师的《css世界》并根据自己的业务做出的一些实践总结。
2019-04-28 - 微信小程序 unionid 登录解决方案
第三方登录模块使开发者能快捷灵活的拥有自己的用户系统,是 LeanCloud 最受欢迎的功能之一。随着第三方平台的演化,特别是微信小程序的流行,LeanCloud 第三方登录模块也一直在改进: v2.0*:增加微信小程序一键登录功能。支持开发者不写任何后端代码实现微信小程序用户系统与 LeanCloud 用户系统的关联。 v3.6:增加 unionid 登录接口。支持开发者使用 unionid 关联一个微信开发者帐号下的多个应用从而共享一套 LeanCloud 用户系统。 这两个功能各自都非常简单可靠,但是其中重叠的部分需求却是一个难题:「如何在小程序中支持 unionid 登录,既能得到 unionid 登录机制的灵活性,又保留一键登录功能的便利性」。 在最近发布的 JavaScript SDK v3.13 中包含了微信小程序 unionid 登录支持。我们根据不同的需求设计了不同的解决方案。 * 这里的版本指开始支持该功能的 JavaScript SDK 版本。 一键登录 LeanCloud 的用户系统支持一键使用微信用户身份登录。要使用一键登录功能,需要先设置小程序的 AppID 与 AppSecret: 1.登录 微信公众平台,在 设置 > 开发设置 中获得 AppID 与 AppSecret。 前往 LeanCloud 控制台 > 组件 > 社交,保存「微信小程序」的 AppID 与 AppSecret。 这样你就可以在应用中使用[代码]AV.User.loginWithWeapp()[代码]方法来使用当前用户身份登录了。 [代码]AV.User.loginWithWeapp().then(user => { this.globalData.user = user; }).catch(console.error); [代码] 使用一键登录方式登录时,LeanCloud 会将该用户的小程序 [代码]openid[代码] 与 [代码]session_key[代码] 等信息保存在对应的 [代码]user.authData.lc_weapp[代码] 属性中,你可以在控制台的 [代码]_User[代码] 表中看到: [代码]{ "authData": { "lc_weapp": { "session_key": "2zIDoEEUhkb0B5pUTzsLVg==", "expires_in": 7200, "openid": "obznq0GuHPxdRYaaDkPOHk785DuA" } } } [代码] 如果用户是第一次使用此应用,调用登录 API 会创建一个新的用户,你可以在 控制台 > 存储 中的 [代码]_User[代码] 表中看到该用户的信息,如果用户曾经使用该方式登录过此应用(存在对应 openid 的用户),再次调用登录 API 会返回同一个用户。 用户的登录状态会保存在客户端中,可以使用 [代码]AV.User.current()[代码] 方法来获取当前登录的用户,下面的例子展示了如何为登录用户保存额外的信息: [代码]// 假设已经通过 AV.User.loginWithWeapp() 登录 // 获得当前登录用户 const user = AV.User.current(); // 调用小程序 API,得到用户信息 wx.getUserInfo({ success: ({userInfo}) => { // 更新当前用户的信息 user.set(userInfo).save().then(user => { // 成功,此时可在控制台中看到更新后的用户信息 this.globalData.user = user; }).catch(console.error); } }); [代码] [代码]authData[代码] 默认只有对应用户可见,开发者可以使用 masterKey 在云引擎中获取该用户的 [代码]openid[代码] 与 [代码]session_key[代码] 进行支付、推送等操作。详情的示例请参考 支付。 小程序的登录态([代码]session_key[代码])存在有效期,可以通过 wx.checkSession() 方法检测当前用户登录态是否有效,失效后可以通过调用 [代码]AV.User.loginWithWeapp()[代码] 重新登录。 使用 unionid 微信开放平台使用 unionid 来区分用户的唯一性,也就是说同一个微信开放平台帐号下的移动应用、网站应用和公众帐号(包括小程序),用户的 unionid 都是同一个,而 openid 会是多个。如果你想要实现多个小程序之间,或者小程序与使用微信开放平台登录的应用之间共享用户系统的话,则需要使用 unionid 登录。 要在小程序中使用 unionid 登录,请先确认已经在 微信开放平台 绑定了该小程序 在小程序中有很多途径可以 获取到 unionid。不同的 unionid 获取方式,接入 LeanCloud 用户系统的方式也有所不同。 一键登录时静默获取 unionid 当满足以下条件时,一键登录 API [代码]AV.User.loginWithWeapp()[代码] 能静默地获取到用户的 unionid 并用 unionid + openid 进行匹配登录。 微信开放平台帐号下存在同主体的公众号,并且该用户已经关注了该公众号。 微信开放平台帐号下存在同主体的公众号或移动应用,并且该用户已经授权登录过该公众号或移动应用。 要启用这种方式,需要在一键登录时指定参数 [代码]preferUnionId[代码] 为 true: [代码]AV.User.loginWithWeapp({ preferUnionId: true, }); [代码] 使用 unionid 登录后,用户的 authData 中会增加 _[代码]weixin_unionid[代码] 一项(与 [代码]lc_weapp[代码] 平级): [代码]{ "authData": { "lc_weapp": { "session_key": "2zIDoEEUhkb0B5pUTzsLVg==", "expires_in": 7200, "openid": "obznq0GuHPxdRYaaDkPOHk785DuA", "unionid": "ox7NLs5BlEqPS4glxqhn5kkO0UUo" }, "_weixin_unionid": { "uid": "ox7NLs5BlEqPS4glxqhn5kkO0UUo" } } } [代码] 用 unionid + openid 登录时,会按照下面的步骤进行用户匹配: 如果已经存在对应 [代码]unionid(authData._weixin_unionid.uid[代码])的用户,则会直接作为这个用户登录,并将所有信息([代码]openid[代码]、[代码]session_key[代码]、[代码]unionid[代码] 等)更新到该用户的 [代码]authData.lc_ewapp[代码] 中。 如果不存在匹配 unionid 的用户,但存在匹配 openid([代码]authData.lc_weapp.openid[代码])的用户,则会直接作为这个用户登录,并将所有信息([代码]session_key[代码]、[代码]unionid[代码] 等)更新到该用户的 [代码]authData.lc_ewapp[代码] 中,同时将 [代码]unionid[代码] 保存到 [代码]authData._weixin_unionid.uid[代码] 中。 如果不存在匹配 unionid 的用户,也不存在匹配 openid 的用户,则创建一个新用户,将所有信息([代码]session_key[代码]、[代码]unionid[代码] 等)更新到该用户的 [代码]authData.lc_ewapp[代码] 中,同时将 [代码]unionid[代码] 保存到 [代码]authData._weixin_unionid.uid[代码] 中。 不管匹配的过程是如何的,最终登录用户的 [代码]authData[代码] 都会是上面这种结构。 LeanTodo Demo 便是使用这种方式登录的,如果你已经关注了其关联的公众号(搜索 AVOSCloud,或通过小程序关于页面的相关公众号链接访问),那么你在登录后会在 LeanTodo Demo 的 设置 - 用户 页面看到当前用户的 [代码]authData[代码] 中已经绑定了 unionid。 [图片] 微信扫描二维码进入 Demo 需要注意的是: 如果用户不符合上述静默获取 unionid 的条件,那么就算指定了 [代码]preferUnionId[代码] 也不会使用 unionid 登录。 如果用户符合上述静默获取 unionid 的条件,但没有指定 [代码]preferUnionId[代码],那么该次登录不会使用 unionid 登录,但仍然会将获取到的 unionid 作为一般字段写入该用户的 [代码]authData.lc_weapp[代码] 中。此时用户的 [代码]authData[代码] 会是这样的: [代码]{ "authData": { "lc_weapp": { "session_key": "2zIDoEEUhkb0B5pUTzsLVg==", "expires_in": 7200, "openid": "obznq0GuHPxdRYaaDkPOHk785DuA", "unionid": "ox7NLs5BlEqPS4glxqhn5kkO0UUo" } } } [代码] 通过其他方式获取 unionid 后登录 如果开发者自行获得了用户的 unionid(例如通过解密 wx.getUserInfo 获取到的用户信息),可以在小程序中调用 [代码]AV.User.loginWithWeappWithUnionId()[代码] 投入 unionid 完成登录授权: [代码]AV.User.loginWithWeappWithUnionId(unionid, { asMainAccount: true }).then(console.log, console.error); [代码] 通过其他方式获取 unionid 与 openid 后登录 如果开发者希望更灵活的控制小程序的登录流程,也可以自行在服务端实现 unionid 与 openid 的获取,然后调用通用的第三方 unionid 登录接口指定平台为 [代码]lc_weapp[代码] 来登录: [代码]const unionid = ''; const authData = { openid: '', session_key: '' }; const platform = 'lc_weapp'; AV.User.loginWithAuthDataAndUnionId(authData, platform, unionid, { asMainAccount: true }).then(console.log, console.error); [代码] 相对上面提到的一些 Weapp 相关的登录 API,loginWithAuthDataAndUnionId 是更加底层的第三方登录接口,不依赖小程序运行环境,因此这种方式也提供了更高的灵活度: 可以在服务端获取到 unionid 与 openid 等信息后返回给小程序客户端,在客户端调用 [代码]AV.User.loginWithAuthDataAndUnionId[代码] 来登录。 也可以在服务端获取到 unionid 与 openid 等信息后直接调用 [代码]AV.User.loginWithAuthDataAndUnionId[代码] 登录,成功后得到登录用户的 [代码]sessionToken[代码] 后返回给客户端,客户端再使用该 [代码]sessionToken[代码] 直接登录。 关联第二个小程序 这种用法的另一种常见场景是关联同一个开发者帐号下的第二个小程序。 因为一个 LeanCloud 应用默认关联一个微信小程序(对应的平台名称是 [代码]lc_weapp[代码]),使用小程序系列 API 的时候也都是默认关联到 [代码]authData.lc_weapp[代码] 字段上。如果想要接入第二个小程序,则需要自行获取到 unionid 与 openid,然后将其作为一个新的第三方平台登录。这里同样需要用到 [代码]AV.User.loginWithAuthDataAndUnionId[代码] 方法,但与关联内置的小程序平台([代码]lc_weapp[代码])有一些不同: 需要指定一个新的 [代码]platform[代码] 需要将 [代码]openid[代码] 保存为 [代码]uid[代码](内置的微信平台做了特殊处理可以直接用 [代码]openid[代码] 而这里是作为通用第三方 OAuth 平台保存因此需要使用标准的 [代码]uid[代码] 字段)。 这里我们以新的平台 [代码]weapp2[代码] 为例: [代码]const unionid = ''; const openid = ''; const authData = { uid: openid, session_key: '' }; const platform = 'weapp2'; AV.User.loginWithAuthDataAndUnionId(authData, platform, unionid, { asMainAccount: true }).then(console.log, console.error); [代码] 获取 unionid 后与现有用户关联 如果一个用户已经登录,现在通过某种方式获取到了其 unionid(一个常见的使用场景是用户完成了支付操作后在服务端通过 getPaidUnionId 得到了 unionid)希望与之关联,可以在小程序中使用 [代码]AV.User#associateWithWeappWithUnionId()[代码]: [代码]const user = AV.User.current(); // 获取当前登录用户 user.associateWithWeappWithUnionId(unionid, { asMainAccount: true }).then(console.log, console.error); [代码] 启用其他登录方式 上述的登录 API 对接的是小程序的用户系统,所以使用这些 API 创建的用户无法直接在小程序之外的平台上登录。如果需要使用 LeanCloud 用户系统提供的其他登录方式,如用手机号验证码登录、邮箱密码登录等,在小程序登录后设置对应的用户属性即可: [代码]// 小程序登录 AV.User.loginWithWeapp().then(user => { // 设置并保存手机号 user.setMobilePhoneNumber('13000000000'); return user.save(); }).then(user => { // 发送验证短信 return AV.User.requestMobilePhoneVerify(user.getMobilePhoneNumber()); }).then({ // 用户填写收到短信验证码后再调用 AV.User.verifyMobilePhone(code) 完成手机号的绑定 // 成功后用户的 mobilePhoneVerified 字段会被置为 true // 此后用户便可以使用手机号加动态验证码登录了 }).catch(console.error); [代码] 验证手机号码功能要求在 控制台 > 存储 > 设置 > 用户账号 启用「用户注册时,向注册手机号码发送验证短信」。 绑定现有用户 如果你的应用已经在使用 LeanCloud 的用户系统,或者用户已经通过其他方式注册了你的应用(比如在 Web 端通过用户名密码注册),可以通过在小程序中调用 [代码]AV.User#associateWithWeapp()[代码] 来关联已有的账户: [代码]// 首先,使用用户名与密码登录一个已经存在的用户 AV.User.logIn('username', 'password').then(user => { // 将当前的微信用户与当前登录用户关联 return user.associateWithWeapp(); }).catch(console.error); [代码] 更多内容欢迎查看《在微信小程序与小游戏中使用 LeanCloud》。
2019-04-28 - 版本管理的基本使用 git基本能力详解
前言: 看完此文后, 可以在 github上新建一个 仓库,之后新建一个小程序项目,上传到github的仓库. 一.git基本功能 讲解 拉取: 获得服务器 指定分支代码 到本地的 head分支(当前分支) 抓取:获取服务器分支的 最新修改,不会合并入本地 推送:将本地分支 推送到服务器指定分支 分支: 基于某个本地分支 创建 新分支 合并:将本地的两个分支 进行合并 用于代码提交 就是讲 某个分支 合并到 当前分支(head标识的分支) 分支理解:每个人开发 的 都是自己的 分支,可以是远程的分支,也可以是本地的分支.本地开发完 合并到 主分支就行了. 二.现有项目代码上传到git. 1.打开现有项目. 2.点击版本管理 ->设置->远程->添加 添加你的git地址. 3.抓取->抓取全部 4.点击远程分支-> 查看嵌入记录 ->右键 从提交 新建分支.分支名称你随意 如果 没有 记录 就可以自己新建一个 分支. 5.之后你就可以推送了. [图片] [图片] 三.推荐使用分支的 标准 每个项目做一个远程dev分支,用于主开发分支.代码同步 开发人员获取 远程dev分支,新建本地dev分支. 基于本地dev分支 新建 本地 开发者 分支. 代码获取: 在本地dev分支 获取远程dev分支的变更,之后 merge 本地dev到 本地开发人员的 本地分支上. 代码嵌入,合并:在本地开发人员分支 变更后,嵌入本地开发人员分支. 之后 切换到本地dev分支,在本地dev分支上 拉取远程dev的最新分支代码. 5.1 将开发人员分支 合并到 本地dev分支,之后推送 到远程dev分支,达到代码嵌入的目的. 四.常见问题. [图片] 这个是你 推送之前 没commit导致的. [图片] 2.这个是 你本地 没有 此分支的 head,你在你想要拉取的 远程分支 的 最新嵌入记录上 右键,获取head,就可以了,相当于重置了 head.重置head方法如下: [图片] 3.拉取远程分支可以 点拉取按钮,也可以如下图. [图片] 4.git 仓库的验证方式 还需要你们自己去选择,因为 仓库的 验证 都是 仓库那边决定的. 在 设置->网络和认证 中 设置. [图片]
2019-04-29 - 蓝牙分包写入
前段时间收到一个蓝牙设备,需求是控制板子上面的电机,这对于从未接触过硬件的小白来说无疑是一个挑战,然而我是一个喜欢挑战的蓝人,于是开始了我的研究。 我开始各种搜罗Demo,查看文档及各大论坛。 终于,黄天不负苦心人,我成功的连接上了设备,并且获取到了他的服务及特征值,当然,每台外设可用的服务及特征值都是不一样的,而且有些是不可用的,什么 read、write、notify、indicate 要根据自己的操作需求去看哪个特征值支持。每个服务下面都有不同的特征值,每个特征值下面又分出来几个不同的特征值列表,接下来就是根据自己需求筛选了。 支持列表: read:读取低功耗蓝牙设备的特征值的二进制数据值 write:向低功耗蓝牙设备特征值中写入二进制数据 notify || indicate:启用低功耗蓝牙设备特征值变化时的 notify 功能,订阅特征值 这些都选好后该开始向蓝牙设备写入指令,让他动起来了(想想就有些小激动) 不过根据官方提供的方式转换指令并写入的时候,意外发生了! 我在回调里面打印成功与否的时候,显示成功,但是设备却没人任何反应,于是我又开始找原因(设备通过某个App试过,写入指令后是正常的),又开始了我的搜寻之旅。 之后发现原因是因为我使用的指令转换后超过了20字节,在Api文档中 https://developers.weixin.qq.com/miniprogram/dev/api/wx.writeBLECharacteristicValue.html 标注了建议每次写入不超过20字节,但是也不是强制性的呀,导致我很懵逼,明明返回的是成功。 [图片] 事到如今,只能接着寻找解决方法了,总不能跟他这么耗着呀。接着我的搜寻之旅吧!(废话那么多,重点该来了) 终于,找到种分包的方式,把超过20字节的指令分批发送(当然,没有超过20字节的话,也不影响使用),这边需要注意一下,不能直接连发,需要有一个延迟,然后~完美写入指令,我的小电机动起来了。 整体的流程如下: 打开蓝牙模块 => 搜索蓝牙 => 获取所有已发现的设备 => 连接蓝牙设备 => 获取蓝牙设备的所有服务 => 获取蓝牙设备服务下的所有特征值 => 向蓝牙设备写入指令 => 完成 =>关闭蓝牙模块 行了,不哔哔了。我结合官方提供的Demo修改了一下,添加了一个分包写入,已经打包成代码片段,可以直接使用。 代码片段中服务和特征值这两个地方我写成了固定的,根据自己的需求可以修改下。 [图片] [图片] 核心代码: 延时定时器 格式转换 判断并分包写入 [图片] [图片] 代码片段: https://developers.weixin.qq.com/s/oFJc70mI7o8K 如有不对的地方或者更好的解决方案,还望大佬们及时提出,希望对你们有所帮助。
2019-05-05 - 用 HTM 实现小程序 SVG
写在前面 今天你可以在小程序中使用 Cax 引擎高性能渲染 SVG! SVG 是可缩放矢量图形(Scalable Vector Graphics),基于可扩展标记语言,用于描述二维矢量图形的一种图形格式。它由万维网联盟制定,是一个开放标准。SVG 的优势有很多: SVG 使用 XML 格式定义图形,可通过文本编辑器来创建和修改 SVG 图像可被搜索、索引、脚本化或压缩 SVG 是可伸缩的,且放大图片质量不下降 SVG 图像可在任何的分辨率下被高质量地打印 SVG 可被非常多的工具读取和修改(比如记事本) SVG 与 JPEG 和 GIF 图像比起来,尺寸更小,且可压缩性、可编程星更强 SVG 完全支持 DOM 编程,具有交互性和动态性 而支持上面这些优秀特性的前提是 - 需要支持 SVG 标签。比如在小程序中直接写: [代码]<svg width="300" height="150"> <rect bindtap="tapHandler" height="100" width="100" style="stroke:#ff0000; fill: #0000ff"> </rect> </svg> [代码] 上面定义了 SVG 的结构、样式和点击行为。但是小程序目前不支持 SVG 标签,仅仅支持加载 SVG 之后 作为 background-image 进行展示,如 [代码]background-image: url("data:image/svg+xml.......)[代码],或者 base64 后作为 background-image 的 url。 直接看在小程序种使用案例: [代码]import { html, renderSVG } from '../../cax/cax' Page({ onLoad: function () { renderSVG(html` <svg width="300" height="220"> <rect bindtap="tapHandler" height="110" width="110" style="stroke:#ff0000; fill: #ccccff" transform="translate(100 50) rotate(45 50 50)"> </rect> </svg>`, 'svg-a', this) }, tapHandler: function () { console.log('你点击了 rect') } }) [代码] 其中的 svg-a 对应着 wxml 里 cax-element 的 id: [代码]<view class="container"> <cax-element id="svg-c"></cax-element> </view> [代码] 声明组件依赖 [代码]{ "usingComponents": { "cax-element":"../../cax/index" } } [代码] 小程序中显示效果: [图片] 可以使用 [代码]width[代码],[代码]height[代码],[代码]bounds-x[代码] 和 [代码]bounds-y[代码] 设置绑定事件的范围,比如: [代码]<path width="100" height="100" bounds-x="50" bounds-y="50" /> [代码] 需要注意的是,元素的事件触发的包围盒受自身或者父节点的 transform 影响,所以不是绝对坐标的 rect 触发区域。 再来一个复杂的例子,用 SVG 绘制 Omi 的 logo: [代码]renderSVG(html` <svg width="300" height="220"> <g transform="translate(50,10) scale(0.2 0.2)"> <circle fill="#07C160" cx="512" cy="512" r="512"/> <polygon fill="white" points="159.97,807.8 338.71,532.42 509.9,829.62 519.41,829.62 678.85,536.47 864.03,807.8 739.83,194.38 729.2,194.38 517.73,581.23 293.54,194.38 283.33,194.38 "/> <circle fill="white" cx="839.36" cy="242.47" r="50"/> </g> </svg>`, 'svg-a', this) [代码] 小程序种显示效果: [图片] 在 omip 和 mps 当中使用 cax 渲染 svg,你可以不用使用 htm。比如在 omip 中实现上面两个例子: [代码] renderSVG( <svg width="300" height="220"> <rect bindtap="tapHandler" height="110" width="110" style="stroke:#ff0000; fill: #ccccff" transform="translate(100 50) rotate(45 50 50)"> </rect> </svg>, 'svg-a', this.$scope) [代码] [代码]renderSVG( <svg width="300" height="220"> <g transform="translate(50,10) scale(0.2 0.2)"> <circle fill="#07C160" cx="512" cy="512" r="512"/> <polygon fill="white" points="159.97,807.8 338.71,532.42 509.9,829.62 519.41,829.62 678.85,536.47 864.03,807.8 739.83,194.38 729.2,194.38 517.73,581.23 293.54,194.38 283.33,194.38 "/> <circle fill="white" cx="839.36" cy="242.47" r="50"/> </g> </svg>, 'svg-a', this.$scope) [代码] 需要注意的是在 omip 中传递的最后一个参数不是 [代码]this[代码],而是 [代码]this.$scope[代码]。 在 mps 中,更加彻底,你可以单独创建 svg 文件,通过 import 导入。 [代码]//注意这里不能写 test.svg,因为 mps 会把 test.svg 编译成 test.js import testSVG from '../../svg/test' import { renderSVG } from '../../cax/cax' Page({ tapHandler: function(){ this.pause = !this.pause }, onLoad: function () { renderSVG(testSVG, 'svg-a', this) } }) [代码] 比如 test.svg : [代码]<svg width="300" height="300"> <rect bindtap="tapHandler" x="0" y="0" height="110" width="110" style="stroke:#ff0000; fill: #0000ff" /> </svg> [代码] 会被 mps 编译成: [代码]const h = (type, props, ...children) => ({ type, props, children }); export default h( "svg", { width: "300", height: "300" }, h("rect", { bindtap: "tapHandler", x: "0", y: "0", height: "110", width: "110", style: "stroke:#ff0000; fill: #0000ff" }) ); [代码] 所以总结一下: 你可以在 mps 中直接使用 import 的 SVG 文件的方式使用 SVG 你可以直接在 omip 中使用 JSX 的使用 SVG 你可以直接在原生小程序当中使用 htm 的方式使用 SVG 这就完了?远没有,看 cax 在小程序中的这个例子: [图片] 详细代码: [代码]renderSVG(html` <svg width="300" height="200"> <path d="M 256,213 C 245,181 206,187 234,262 147,181 169,71.2 233,18 220,56 235,81 283,88 285,78.7 286,69.3 288,60 289,61.3 290,62.7 291,64 291,64 297,63 300,63 303,63 309,64 309,64 310,62.7 311,61.3 312,60 314,69.3 315,78.7 317,88 365,82 380,56 367,18 431,71 453,181 366,262 394,187 356,181 344,213 328,185 309,184 300,284 291,184 272,185 256,213 Z" style="stroke:#ff0000; fill: black"> <animate dur="32s" repeatCount="indefinite" attributeName="d" values="......太长,这里省略 paths........" /> </path> </svg>`, 'svg-c', this) [代码] 再试试著名的 SVG 老虎: [图片] path 太长,就不贴代码了,可以点击这里查看 pasiton 标签 [代码]import { html, renderSVG } from '../../cax/cax' Page({ onLoad: function () { const svg = renderSVG(html` <svg width="200" height="200"> <pasition duration="200" bindtap=${this.changePath} width="100" height="100" from="M28.228,23.986L47.092,5.122c1.172-1.171,1.172-3.071,0-4.242c-1.172-1.172-3.07-1.172-4.242,0L23.986,19.744L5.121,0.88 c-1.172-1.172-3.07-1.172-4.242,0c-1.172,1.171-1.172,3.071,0,4.242l18.865,18.864L0.879,42.85c-1.172,1.171-1.172,3.071,0,4.242 C1.465,47.677,2.233,47.97,3,47.97s1.535-0.293,2.121-0.879l18.865-18.864L42.85,47.091c0.586,0.586,1.354,0.879,2.121,0.879 s1.535-0.293,2.121-0.879c1.172-1.171,1.172-3.071,0-4.242L28.228,23.986z" to="M49.1 23.5H2.1C0.9 23.5 0 24.5 0 25.6s0.9 2.1 2.1 2.1h47c1.1 0 2.1-0.9 2.1-2.1C51.2 24.5 50.3 23.5 49.1 23.5zM49.1 7.8H2.1C0.9 7.8 0 8.8 0 9.9c0 1.1 0.9 2.1 2.1 2.1h47c1.1 0 2.1-0.9 2.1-2.1C51.2 8.8 50.3 7.8 49.1 7.8zM49.1 39.2H2.1C0.9 39.2 0 40.1 0 41.3s0.9 2.1 2.1 2.1h47c1.1 0 2.1-0.9 2.1-2.1S50.3 39.2 49.1 39.2z" from-stroke="red" to-stroke="green" from-fill="blue" to-fill="red" stroke-width="2" /> </svg>`, 'svg-c', this) this.pasitionElement = svg.children[0] }, changePath: function () { this.pasitionElement.toggle() } }) [代码] pasiton 提供了两个 path 和 颜色 相互切换的能力,最常见的场景比如 menu 按钮和 close 按钮点击后 path 的变形。 举个例子,看颜色和 path 同时变化: [图片] 线性运动 这里举一个在 mps 中使用 SVG 的案例: [代码]import { renderSVG, To } from '../../cax/cax' Page({ tapHandler: function(){ this.pause = !this.pause }, onLoad: function () { const svg = renderSVG(html` <svg width="300" height="300"> <rect bindtap="tapHandler" x="0" y="0" height="110" width="110" style="stroke:#ff0000; fill: #0000ff" /> </svg>` , 'svg-a', this) const rect = svg.children[0] rect.originX = rect.width/2 rect.originY = rect.height/2 rect.x = svg.stage.width/2 rect.y = svg.stage.height/2 this.pause = false this.interval = setInterval(()=>{ if(!this.pause){ rect.rotation++ svg.stage.update() } },15) }) [代码] 效果如下: [图片] 组合运动 [代码]import { renderSVG, To } from '../../cax/cax' Page({ onLoad: function () { const svg = renderSVG(html` <svg width="300" height="300"> <rect bindtap="tapHandler" x="0" y="0" height="110" width="110" style="stroke:#ff0000; fill: #0000ff" /> </svg>` ,'svg-a', this) const rect = svg.children[0] rect.originX = rect.width/2 rect.originY = rect.height rect.x = svg.stage.width/2 rect.y = svg.stage.height/2 var sineInOut = To.easing.sinusoidalInOut To.get(rect) .to().scaleY(0.8, 450, sineInOut).skewX(20, 900, sineInOut) .wait(900) .cycle().start() To.get(rect) .wait(450) .to().scaleY(1, 450, sineInOut) .wait(900) .cycle().start() To.get(rect) .wait(900) .to().scaleY(0.8, 450, sineInOut).skewX(-20, 900, sineInOut) .cycle() .start() To.get(rect) .wait(1350) .to().scaleY(1, 450, sineInOut) .cycle() .start() setInterval(() => { rect.stage.update() }, 16) } }) [代码] 效果如下: [图片] 其他 vscode 安装 lit-html 插件使 htm 的 html[代码]内容[代码] 高亮 还希望小程序 SVG 提供什么功能可以开 issues告诉我们,评估后通过,我们去实现! Cax Github 参考文档
01-04 - request封装
fetch.js [代码]const api = 'www.qq.com' export const Fetch = ({ url = '', data = {}, header = { "content-type": "application/json" }, method = 'GET', api = Api }) => { return new Promise((resolve, reject) => { wx.request({ url: api + url, header: header, method: method, data: data, success: res => { // 成功时的处理 if (res.data.error == 0) { resolve(res.data); } else { reject(res.data); } }, fail: err => { reject(err); } }) }) } [代码] api.js [代码]import { Fetch } from './fetch.js'; export const PostMiniList = data => { return Fetch({ url: '/post/post_mini_list.json', data: data, method: 'POST', header: { 'content-type': 'application/x-www-form-urlencoded' } }) } export const GetMiniList = data => { return Fetch({ url: '/get/get_mini_list.json', data: data }) } [代码] index.js [代码]import { PostMiniList, GetMiniList } from './api.js'; PostMiniList({ a:1, b:2 }).then( res => { // 成功处理 }, err => { // 失败处理 } ) GetMiniList({ a:1, b:2 }).then( res => { // 成功处理 }, err => { // 失败处理 } ) [代码] 把所有api放在api.js里统一管理,利用promise使我们只关注返回的结果
2019-05-06 - 都 9102 年了,TypeScript 了解一下
前言 此文章为 SlugTeam 大前端技术沙龙 TypeScript 主题分享 PPT 文字内容。SlugTeam 大前端是腾讯互娱市场平台部营销技术中心下属几个开发组前端技术人员的联合,自去年 9 月起,SlugTeam 大前端每 3 个星期举行 1 次技术沙龙,沙龙主题由团队成员推荐并投票选出。 本次 3 月 28 日沙龙主题为 TypeScript。TypeScript 是由微软开发并开源的一门编程语言,其作为 Javascript 的超集被广大 Web 前端所熟知,即使在实际工作中尚未使用,也一定听说过 TypeScript。 接下来,让我们一起走近 TypeScript,了解这门越来越火的编程语言。 1. TypeScript 简介 1.1 JavaScript 的诞生 1995 年,网景公司发布了一门叫做 Javascript 的脚本语言,它由当时 34 岁的系统程序员 Brendan Eich 设计,仅仅用于在浏览器实现一些简单的网页互动,如表单验证。 Brendan Eich 做梦也没想到,自己花了十天仓促设计出来的 JavaScript,一经推出就被广泛接受,获得了全世界范围内大量的用户使用。JavaScript 这样连续的爆发式扩散增长,使得语言规格调整困难重重。 1996 年,微软公司也推出了自己的脚本 JScript。为了压制微软,网景公司决定申请 JavaScript 国际标准。 1997 年 6 月,第一个国际标准 ECMA-262 正式颁布。 从推出到颁布标准,JavaScript 仅用了一年半时间,语言的缺陷没有充分暴露,就已经被标准固化下来。 1.2 AJAX 的流行 在 2005 年以前,Web 应用程序开发还停留在完成简单的交互逻辑,受限于浏览器技术,Web 应用程序的交互行为十分单调,缺失像桌面应用那样丰富的交互,每次用户与服务器交互都需要重新刷新页面才能够看到效果。 2005 年初,Google 在其著名的 Web 交互应用程序中大量使用了 AJAX,如 Google、Google Map、Gmail等,无需刷新页面就能实现页面的异步更新。Google 对 AJAX 技术的实践,迅速提高了人们使用该项技术的意识。而使用了 AJAX 技术的 Web 应用程序,可以模拟出传统桌面应用的使用体验。 从此,Web 应用程序逐渐变得复杂而丰富,出现了越来越多的大型 Web 应用程序。 1.3 TypeScript 的由来 正如上面介绍的那样,JavaScript 设计之初就没考虑过可以用来编写大型应用。 JavaScript 诞生之初时的 Web 应用程序也就只有几十行或者几百行代码。到了 2010 年,Web 应用程序的代码已经达到了成千上万行。 2010 年的 JavaScript 标准为 ECMAScript 5.0 版本,尚未拥有类、模块等适合大型应用开发的概念。 与此同时 JavaScript 是动态脚本语言,这意味着没有静态类型,使得 IDE 无法提供诸如“代码补全”、“IDE 重构”(借助于 IDE 来对代码进行重构)、“属性查询”、“跳转到函数定义”等强大功能。 再加上 JavaScript 本身语言上的缺陷,使用 JavaScript 编写大型应用成了一件非常艰巨的任务。 微软卓越的工程师 Anders Hejlsberg(Delphi 和 C# 之父),留意到过去五年听到越来越多开发者吐槽 “JavaScript 难以编写大型应用程序”,使得他思考起未来 JavaScript 将何去何从。 Anders Hejlsberg 发现,不少开发者为了编写大型 Web 应用程序,会选择使用 GWT(Google Web Toolkit, 将 Java 代码编译成 JavaScript) 或者 Script# (将 C# 代码编译成 JavaScript),这样他们就可以借助 eclipse 这种强大的 IDE 辅助开发。 但是这种模式有着很大的缺点:在浏览器上面调试一堆由编译器生成的 JavaScript 是件很让人崩溃的事情。而且程序员写的是 Java,出来的是 JavaScript,这本身就是一件很违和的事情。 于是,Anders Hejlsberg 把心思放在了增强 JavaScript 能力上面,思考如何在提供诸如类、模块、可选的静态类型等概念的同时,又不牺牲 JavaScript 现有的优点。 2012 年 10 月,Anders Hejlsberg 带领团队开发出了首个公开版本的 TypeScript。 2 TypeScript 知识 按照官方的说法: (1) TypeScript 设计目标是开发大型应用 (2) TypeScript 是 JavaScript 的严格超集,任何现有的 JavaScript 程序都是合法的 TypeScript 程序,包括各种 JavaScript 库 (3) TypeScript 增加了静态类型、类、模块、接口和类型注解 接下来,我们将通过一些实际例子,来学习了解 TypeScript (非教程,若想深入学习请到官网)。 2.1静态类型批注 [代码]/** * @param (string) x */ function process(x){ x.name = 'foo'; var v = x + x; alert(v); } [代码] 以上是一段日常很常见的 JavaScript 代码,定义了一个名为 process 的函数,传入参数 x,并利用工具生成参数注释。 而在 TypeScript 中,我们可以选择给参数添加类型批注: [代码]function process(x:string){ x.name = 'foo'; var v = x + x; alert(v); } [代码] 加了类型批注后,编译时候就会启动类型检查:“name” 下面出现了红色的错误提示波浪线,将鼠标移动至上面,则提示 “Property ‘name’ does not exits on type ‘string’.”。 继续将鼠标移动到第二行的 “v” 上面,提示 “(local var) v:string”,TypeScript 根据 x 的类型批注,很智能地推断出了 v 的类型。 [代码]function process(x: boolean){ var v = x + x } [代码] 我们试着把 x 标注为 boolean 类型,修改后 ‘x + x’ 下面会出现错误提示波浪线,提示 “Operator ‘+’ cannot be applied to types ‘boolean’ and ‘boolean’.”。 [代码]function process(x:string[]){ x[0]. } [代码] 我们试着将 x 类型批注改为字符串数组,输入 ‘x[0].’ 后,编译器会显示对应的智能提示。 该功能十分强大,它使得开发者不需要记忆过多的 api 或 property,直接就能在右侧找到目标对应的 api 或者 property。 开发者也无需担心这些 api 或 property 会出现张冠李戴的问题,能展示出来的方法或者属性在语法上都是正确的。 像 sublime text 之类的编辑器插件,虽然也能够提供代码补全,但是有可能补全的 api 或者 property 是错误的,因为它们的原理是根据输入的字符串做一些简单的匹配。 例如输入 ‘document’,接着输入’.g’,也即是 ‘document.g’,插件匹配到该字符串后,代码补全提示仅仅显示 ‘getElementsByTagName’ 方法。 这在 TypeScript 未出现之前,的确为开发提供了很大的便利。但是和 TypeScript 的代码补全对比起来,还是显得不够智能。 传统的参数注释,遇上结构化的对象参数,就很束手无策了。而在 TypeScript 中,我们可以标注任何类型的参数: [代码]interface Thing{ a: number; b: string; c?: boolean; } function process(x: Thing){ return x.c; } var n = process({a:10, b:"hello"}); [代码] 上面代码中,在参数后面添加 ‘?’,表示参数可选。 [代码]interface Thing{ a: number; b: string; foo(s: string): string; foo(n: number): number; } function process(x: Thing){ return x.foo("12"); //return x.foo(2); } [代码] 此外,TypeScript 还支持方法重载。 上面的 foo 方法也可以写成下面的格式: [代码]interface Thing{ a: number; b: string; foo: { (s: string): string; (n: number): number; data: any; }; } function process(x: Thing){ return x.foo.data; //return x.foo(2); } [代码] 对于函数,TypeScript 也能够批注其为某个接口的实现。 [代码]interface Accumulator { clear(): void; add(x:number): void; result(): number; } function makeAccumulator(): Accumulator{ var sum = 0; return { clear: function() {sum = 0}, addx: function(value: number){sum += value}, result: function(){return sum} } } var a = makeAccumulator(); a.add(5); [代码] makeAccumulator 里面的 ‘addx: function(value: number){sum += value}’ 将会被标红,因为 Accumulator 接口并没有定义 addx 函数。 [代码]window.onmousemove = function(e){return e.clientX}; var hash = location.hash; [代码] TypeScript 内部维护了一份 DOM/BOM 的方法属性,省去了用户自己添加对应批注的功夫。例如将鼠标移动到 ‘hash’ 上面,会提示 ‘var hash: string’,TypeScript 就能自动推断出类型。 TypeScript 最终将会编译成 JavaScript,在 TypeScript 上面定义的类型,编译后实际上是不存在的。 [代码]addingTypes.ts function Greeter(greeting: string) { this.greeting = greeting; } Greeter.prototype.greet = function() { return "Hello, " + this.greeting; } let greeter = new Greeter("world"); let button = document.createElement('button'); button.textContent = "Say Hello"; button.onclick = function() { alert(greeter.greet()); }; document.body.appendChild(button); [代码] 上面 TypeScript 文件编译为 JavaScript 后,将是下面的样子: [代码]addingTypes.js function Greeter(greeting) { this.greeting = greeting; } Greeter.prototype.greet = function () { return "Hello, " + this.greeting; }; var greeter = new Greeter("world"); var button = document.createElement('button'); button.textContent = "Say Hello"; button.onclick = function () { alert(greeter.greet()); }; document.body.appendChild(button); [代码] 2.2 类 [代码]class Point{ x: number; y: number; private color: string; constructor(x: number, y: number){ this.x = x; this.y = y; this.color = "red"; } dist(){ return Math.sqrt(this.x * this.x +this.y * this.y);} static origin = new Point(0,0); } var p = new Point(10, 20); p.x = 10; p.y = 20; //p.color = "green" [代码] 在 TypeScript 的类中,访问修饰符 public、private、protected、static 和 JAVA、C# 等语言类似。 [代码]class Point{ private color: string; constructor(public x: number = 0, public y: number = 0){ this.color = "red"; } dist(){return Math.sqrt(this.x * this.x + this.y * this.y)} static origin = new Point(0,0); } var p = new Point(); p.x = 10; p.y = 20; [代码] TypeScript 支持默认值,用法和 ES6 类似。 接下来,我们看一下 TypeScript 如何实现类的继承。 [代码]class Point{ private color: string; constructor(public x: number = 0, public y: number = 0){ this.color = "red"; } dist(){return Math.sqrt(this.x * this.x + this.y * this.y)} static origin = new Point(0,0); } class Point3D extends Point{ constructor(x: number, y: number, public z:number){ super(x,y); } dist() { var d = super.dist(); return Math.sqrt(d * d + this.z * this.z) } } [代码] 让我们继续看下一段代码 [代码]class Tracker{ count = 0; start(){ window.onmousemove = function(e){ this.count++; console.log(this.count); } } } var t = new Tracker(); t.start(); [代码] 这段代码在 ‘count++’ 处有个错误提示, “Property ‘count’ does not exist on type ‘GlobalEventHandlers’”,这也是新手在写 JavaScript 时候容易犯的一个错误,而且还不容易察觉。 我们可以使用箭头函数来修复这错误。 [代码]class Tracker{ count = 0; start(){ window.onmousemove = e => { this.count++; console.log(this.count); } } } var t = new Tracker(); t.start(); [代码] 2.3 模块 [代码]module Utils{ export class Tracker{ count = 0; start(){ window.onmousemove = e => { console.log(this.count) } } } } module Utils { export var greeting = "hello" } var t = new Utils.Tracker(); t.start; [代码] 假如模块带有很长的命名空间,如下面的代码 [代码]module Acme.Core.Utils{ export class Tracker{ count = 0; start(){ window.onmousemove = e => { console.log(this.count) } } } } var t = new Acme.Core.Utils.Tracker(); t.start; [代码] 我们可以 import 的形式来缩短命名空间。 [代码]module Acme.Core.Utils{ export class Tracker{ count = 0; start(){ window.onmousemove = e => { console.log(this.count) } } } } import ACM = Acme.Core.Utils; var t = new ACM.Tracker(); t.start; [代码] 假如我们要使用诸如 Nodejs 的模块,我们要先安装对应的 @types 文件,基本上流行的库都有社区维护的 type 文件。 网站 [代码]https://microsoft.github.io/TypeSearch/[代码] 可以查询相关库的 type 文件。 接下来拿 Nodejs 做个演示。 [代码]yarn global add typescript cd typescript_demo tsc --init yarn add @types/node [代码] 在目录打开 [代码]tsconfig.json[代码] 文件,修改对应字段 [代码]"typeRoots": [ "./node_modules/@types" ], "esModuleInterop": true [代码] 创建 server.ts 和 hello.ts,代码分别如下 [代码]server.ts import * as http from 'http' //import http from 'http' //上面写法会报“TS1192: Module '"http"' has no default export” export function simpleServer(port: number, message: string){ http.createServer((req, res) => { res.writeHead(200, {'Content-Type': 'text/html'}); res.write(`<h1>${message}</h1>`); res.end() }).listen(port) } [代码] [代码]hello.ts import {simpleServer} from './server'; simpleServer(1337, "Greetings Channel 9"); console.log("Listening..."); [代码] 保存后,编译 hello.ts 并运行 hello.js。 [代码]tsc hello.ts node hello [代码] 在浏览器访问 [代码]localhost:1337[代码] 可看到页面输出 [代码]Greetings Channel 9[代码]。 2.4 Visual Studio Code 与 TypeScript Visual Studio Code 是前端界最流行的 IDE,由微软开发并开源。 Visual Studio Code 加上 TypeScript,代码重构将变得很轻松。 我们在进行代码重构的时候,常常会遇上这么一种情况:修改了方法名或者属性名,还得找到引用它们的地方,逐个修改。假如引用的地方很多,修改将很耗费时间,也很繁琐,而且还可能修改得不够彻底,造成程序错误。 有了 TypeScript 后,在 Visual Studio Code 上,只需要在方法或者属性定义处,右键选择“重命名符号”,输入新的名字,回车后所有的引用将自动更新该变化。 2.5 TypeScript 与热门前端框架 前端目前流行三大框架: Angular、React、Vue。 其中 Angular 基于 TypeScript 来开发,React 在开发的时候可以选择引入 TypeScript,而对于 Vue,作者是这么说的: 必须要承认的是,2.x 的 TS 支持显然跟 React 和 Angular 是有差距的,这也是为什么 3.0 要加强这一块 这里推荐看一下尤雨溪在知乎上面的回答:TypeScript 不适合在 vue 业务开发中使用吗? 虽然目前 Vue 对 TypeScript 的支持不是很完美,但是大部分 TypeScript 的功能还是可以用,应用到生产环境中是个不错的选择。 Vue CLI3 已经支持生成 TypeScript 项目,假如是使用 React 的话,可以选择用 create-react-app 创建项目。 3. TypeScript 相关 3.1 TypeScript 与 ESLint 在使用 TypeScript 的时候,一般也会加入 TSLint,TSLint 事实上已经是 TypeScript 项目的标准静态代码分析工具。TSLint 的生态由一个核心的规范集,社区维护的多种自定义规则以及配置包组成。 ESLint 是 JavaScript 的标准静态代码分析工具。相对于 TSLint,ESLint 支持 TSLint 所缺少的很多功能,如条件 lint 配置和自动缩进。 有一段时间,TypeScript 的代码检查主要有两个方案:使用 TSLint 或使用 ESLint + typescript-eslint-parser。 在 2019 年年初,由于效能问题, TypeScript 官方决定全面采用 ESLint。 接下来 ESLint 团队将不再继续维护 typescript-eslint-parser,他们会封存储存库,也不会在 Npm 发布 typescript-eslint-parser,原本使用 typescript-eslint-parser 的开发者应使用 typescript-eslint/ parser 替代 3.2 Deno “Node 现在太难用了!”,Nodejs之父 Ryan Dahl 去年年初要开发一款 JavaScript 数据互动分析工具的时候,忍不住抱怨自己十年前创造的技术。 尽管 Nodejs 大受欢迎,但是 Ryan Dahl 在 2018 年的演讲时,坦言 Nodejs 有十大设计错误。 Ryan Dahl 决定偿还当年的技术债,打造一个全新的服务端 JavaScript 运行环境,也就是 Deno 项目。 Deno 跟 Nodejs 一样采用了 V8 引擎,但 Deno 是以 TypeScript 为基础,提高了代码的准确性,并将编译器内置到 Deno 可执行文件中。 需要一提的是,Deno 项目现在属于飞速发展的阶段,源码随时可能更新。 4 总结 4.1 TypeScript 优点 解决了 IDE 无法智能提示的问题 函数文档化,无需看接口文档即可直观了解函数参数及对应类型 类型检查以及错误提示 放心地进行代码重构 提供了业界认可的类、泛型、封装、接口面向对象设计能力,以提升 JavaScript 的面向对象设计能力 4.2 TypeScript 缺点 npm 绝大多数模块没有类型注解,假如在 @types 里面也没有找到对应的类型定义文件 (*.d.ts),需要自己手写一份 额外的语法学习成本 TypeScript 配合 webpack 或者 babel 等工具时需要额外处理一些异常 5 后记 在沙龙最后的讨论阶段,SlugTeam 成员一致认为 TypeScript 代表了前端未来发展的方向,项目迁移成本不算高,可以逐步推广起来。 还没了解 TypeScript 的同学,强烈安利:)
2019-04-17 - 自适应 tabBar 组件 不是底部的哦
https://developers.weixin.qq.com/s/47VZSGmR7Q7w 这是代码片段链接 项目中有好多地方都需要用到 navbar ,一个项目中重复的使用同一段代码感觉很烦人,所以就自己写了一个,适合 2-4 个 tab,【支持多个】多个的话稍微修改一下布局就可以了 ━((′д`)爻(′д`))━!!!-图片传不上去 大家可以 打开代码链接看一下 如果感觉写的还凑合的 帮忙点个赞! 有什么可以改进的也可以 在下方评论
2019-04-17 - 图片实现渐变/透明效果
众所周知,图片等一些盒子都可以利用opacity属性来设置不透明度,但是前两天我朋友忽然给我一个截图,截图效果如下 [图片] 图中红框圈住的位置图片或者说摄像头采集的画面出现了渐变到透明,可以清楚的看到可以看到后面小哥的胳膊,然后问我如何实现这种效果,这下把我难住了(呵 天天给我出难题),我开始在个大论坛开始寻找解决方案; 忽然在前天,日常逛论坛时看到一个文字投影的效果,而后忽然灵机一动就想,能不能变相的实现前两天我想要的那种效果,于是乎赶紧打开编辑器试了下,发现确实可以把我想要的图片或者盒子进行投影并给投影设置上渐变颜色及透明,结果出来了,只不过出来的效果他反了 [图片] 随后利用transform: rotate(180deg);控制他使出倒挂金钩此等功夫,果然不负所望,成功翻转过来 [图片] 但是我想要的只有投影,因为我想要效果目前只能用投影去实现去控制,但是他却本体与投影共同出现了,我不想看到本体,太丑了,怎么办呢,那就给他装个position: absolute; top给他爸爸装个position: relative; overflow: hidden;让他滚出~,结果显而易见,我胜利了; [图片] 我得到了我想要的结果,为了验证结果,我用文字放在他的下方 看看是否透明; [图片] 我真的成功了,哈哈(小开心一会儿),为了再次确认他真是的图片实现了渐变透明,我把渐变的透明度改成了1(也就是不透明) [图片] 事实证明,我真的成功了!!! 吹完牛皮,赶紧附上完成代码: css: [图片] html: [图片] 最终效果图: [图片] 呃…其实核心就是利用投影来完成的-webkit-box-reflect: below 0 linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 1) 100%); https://www.w3cschool.cn/css3/box-reflect.html 当然 肯定有大佬在我之前发现这种实现方式,不过当时我找了很久都没找到实现方式的写法,想了想 就发出来吧,如果有什么不对的地方,或者有其他方式也可以实现同等效果的话 还劳请告知,在下多谢各位大佬了!!!
2019-04-19 - 小程序云函数的高级玩法-路由
一般情况下,一个云函数完成单一的逻辑功能,就是一个类的方法一样,如图: [图片] 但是受限免费用户最多只能使用20个云函数,想要在单一云函数中实现多个复杂的功能就需要通过参数来区别,可读性差,不利于管理。通过路由,尝试将请求归类,一个云函数处理某一类的请求,比如有专门负责处理用户的,或者专门处理支付的云函数。如图: [图片] 为了方便大家试用,腾讯云 Tencent Cloud Base 团队开发了 tcb-router,云函数路由管理库方便大家使用。 基于 koa 风格的小程序·云开发云函数轻量级类路由库,主要用于优化服务端函数处理逻辑 使用 npm install --save tcb-router 云函数端 // 云函数的 index.js const TcbRouter = require(’./router’); exports.main = (event, context) => { const app = new TcbRouter({ event }); [代码]// app.use 表示该中间件会适用于所有的路由 app.use(async (ctx, next) => { ctx.data = {}; await next(); // 执行下一中间件 }); // 路由为数组表示,该中间件适用于 user 和 timer 两个路由 app.router(['user', 'timer'], async (ctx, next) => { ctx.data.company = 'Tencent'; await next(); // 执行下一中间件 }); // 路由为字符串,该中间件只适用于 user 路由 app.router('user', async (ctx, next) => { ctx.data.name = 'heyli'; await next(); // 执行下一中间件 }, async (ctx, next) => { ctx.data.sex = 'male'; await next(); // 执行下一中间件 }, async (ctx) => { ctx.data.city = 'Foshan'; // ctx.body 返回数据到小程序端 ctx.body = { code: 0, data: ctx.data}; }); // 路由为字符串,该中间件只适用于 timer 路由 app.router('timer', async (ctx, next) => { ctx.data.name = 'flytam'; await next(); // 执行下一中间件 }, async (ctx, next) => { ctx.data.sex = await new Promise(resolve => { // 等待500ms,再执行下一中间件 setTimeout(() => { resolve('male'); }, 500); }); await next(); // 执行下一中间件 }, async (ctx)=> { ctx.data.city = 'Taishan'; // ctx.body 返回数据到小程序端 ctx.body = { code: 0, data: ctx.data }; }); return app.serve(); [代码] } tips: 小程序云函数的 node 环境默认支持 async/await 语法,推荐涉及到的异步操作时像 demo 中那样使用 小程序端 // 调用名为 router 的云函数,路由名为 user wx.cloud.callFunction({ // 要调用的云函数名称 name: “router”, // 传递给云函数的参数 data: { $url: “user”, // 要调用的路由的路径,传入准确路径或者通配符* other: “xxx” } }); 完整的实例,请参考我的另一篇博客: 分享使用tcb-router路由开发的云函数短信平台SDK
2019-04-20 - 通过授权登录介绍小程序原生开发如何引入async/await、状态管理等工具
登陆和授权是小程序开发会遇到的第一个问题,这里把相关业务逻辑、工具代码抽取出来,展示我们如何引入的一些包使得原生微信小程序内也可以使用 async/await、fetch、localStorage、状态管理、GraphQL 等等特性,希望对大家有所帮助。 前端 目录结构 [代码]├── app.js ├── app.json ├── app.wxss ├── common │ └── api │ └── index.js ├── config.js ├── pages │ └── index │ ├── api │ │ └── index.js │ ├── img │ │ ├── btn.png │ │ └── bg.jpg │ ├── index.js │ ├── index.json │ ├── index.wxml │ └── index.wxss ├── project.config.json ├── store │ ├── action.js │ └── index.js ├── utils │ └── index.js └── vendor ├── event-emitter.js ├── fetch.js ├── fetchql.js ├── http.js ├── promisify.js ├── regenerator.js ├── storage.js └── store.js [代码] 业务代码 app.js [代码]import store from './store/index' const { loginInfo } = store.state App({ store, onLaunch() { // 打开小程序即登陆,无需用户授权可获得 openID if(!loginInfo) store.dispatch('login') }, }) [代码] store/index.js [代码]import Store from '../vendor/store' import localStorage from '../vendor/storage' import actions from './action' const loginInfo = localStorage.getItem('loginInfo') export default new Store({ state: { // 在全局状态中维护登陆信息 loginInfo, }, actions, }) [代码] store/action.js [代码]import regeneratorRuntime from '../vendor/regenerator'; import wx from '../vendor/promisify'; import localStorage from '../vendor/storage' import api from '../common/api/index'; export default { async login({ state }, payload) { const { code } = await wx.loginAsync(); const { authSetting } = await wx.getSettingAsync() // 如果用户曾授权,直接可以拿到 encryptedData const { encryptedData, iv } = authSetting['scope.userInfo'] ? await wx.getUserInfoAsync({ withCredentials: true }) : {}; // 如果用户未曾授权,也可以拿到 openID const { token, userInfo } = await api.login({ code, encryptedData, iv }); // 为接口统一配置 Token getApp().gql.requestObject.headers['Authorization'] = `Bearer ${token}`; // 本地缓存登陆信息 localStorage.setItem('loginInfo', { token, userInfo } ) return { loginInfo: { token, userInfo } } } } [代码] common/api/index.js [代码]import regeneratorRuntime from '../../vendor/regenerator.js' export default { /** * 登录接口 * 如果只有 code,只返回 token,如果有 encryptedData, iv,同时返回用户的昵称和头像 * @param {*} param0 */ async login({ code, encryptedData, iv }) { const query = `query login($code: String!, $encryptedData: String, $iv: String){ login(code:$code, encryptedData:$encryptedData, iv:$iv, appid:$appid){ token userInfo { nickName avatarUrl } } }` const { login: { token, userInfo } } = await getApp().query({ query, variables: { code, encryptedData, iv } }) return { token, userInfo } }, } [代码] pages/index/index.js [代码]import regeneratorRuntime from '../../vendor/regenerator.js' const app = getApp() Page({ data: {}, onLoad(options) { // 将用户登录信息注入到当前页面的 data 中,并且当数据在全局范围内被更新时,都会自动刷新本页面 app.store.mapState(['loginInfo'], this) }, async login({ detail: { errMsg } }) { if (errMsg === 'getUserInfo:fail auth deny') return app.store.dispatch('login') // 继续处理业务 }, }) [代码] pages/index/index.wxml [代码]<view class="container"> <form report-submit="true" bindsubmit="saveFormId"> <button form-type="submit" open-type="getUserInfo" bindgetuserinfo="login">登录</button> </form> </view> [代码] 工具代码 事件处理 vendor/event-emitter.js [代码]const id_Identifier = '__id__'; function randomId() { return Math.random().toString(36).substr(2, 16); } function findIndexById(id) { return this.findIndex(item => item[id_Identifier] === id); } export default class EventEmitter { constructor() { this.events = {} } /** * listen on a event * @param event * @param listener */ on(event, listener) { let { events } = this; let container = events[event] || []; let id = randomId(); let index; listener[id_Identifier] = id; container.push(listener); return () => { index = findIndexById.call(container, id); index >= 0 && container.splice(index, 1); } }; /** * remove all listen of an event * @param event */ off (event) { this.events[event] = []; }; /** * clear all event listen */ clear () { this.events = {}; }; /** * listen on a event once, if it been trigger, it will cancel the listner * @param event * @param listener */ once (event, listener) { let { events } = this; let container = events[event] || []; let id = randomId(); let index; let callback = () => { index = findIndexById.call(container, id); index >= 0 && container.splice(index, 1); listener.apply(this, arguments); }; callback[id_Identifier] = id; container.push(callback); }; /** * emit event */ emit () { const { events } = this; const argv = [].slice.call(arguments); const event = argv.shift(); ((events['*'] || []).concat(events[event] || [])).map(listener => self.emitting(event, argv, listener)); }; /** * define emitting * @param event * @param dataArray * @param listener */ emitting (event, dataArray, listener) { listener.apply(this, dataArray); }; } [代码] 封装 wx.request() 接口 vendor/http.js [代码]import EventEmitter from './event-emitter.js'; const DEFAULT_CONFIG = { maxConcurrent: 10, timeout: 0, header: {}, dataType: 'json' }; class Http extends EventEmitter { constructor(config = DEFAULT_CONFIG) { super(); this.config = config; this.ctx = wx; this.queue = []; this.runningTask = 0; this.maxConcurrent = DEFAULT_CONFIG.maxConcurrent; this.maxConcurrent = config.maxConcurrent; this.requestInterceptor = () => true; this.responseInterceptor = () => true; } create(config = DEFAULT_CONFIG) { return new Http(config); } next() { const queue = this.queue; if (!queue.length || this.runningTask >= this.maxConcurrent) return; const entity = queue.shift(); const config = entity.config; const { requestInterceptor, responseInterceptor } = this; if (requestInterceptor.call(this, config) !== true) { let response = { data: null, errMsg: `Request Interceptor: Request can\'t pass the Interceptor`, statusCode: 0, header: {} }; entity.reject(response); return; } this.emit('request', config); this.runningTask = this.runningTask + 1; let timer = null; let aborted = false; let finished = false; const callBack = { success: (res) => { if (aborted) return; finished = true; timer && clearTimeout(timer); entity.response = res; this.emit('success', config, res); responseInterceptor.call(this, config, res) !== true ? entity.reject(res) : entity.resolve(res); }, fail: (res) => { if (aborted) return; finished = true; timer && clearTimeout(timer); entity.response = res; this.emit('fail', config, res); responseInterceptor.call(this, config, res) !== true ? entity.reject(res) : entity.resolve(res); }, complete: () => { if (aborted) return; this.emit('complete', config, entity.response); this.next(); this.runningTask = this.runningTask - 1; } }; const requestConfig = Object.assign(config, callBack); const task = this.ctx.request(requestConfig); if (this.config.timeout > 0) { timer = setTimeout(() => { if (!finished) { aborted = true; task && task.abort(); this.next(); } }, this.config.timeout); } } request(method, url, data, header, dataType = 'json') { const config = { method, url, data, header: { ...header, ...this.config.header }, dataType: dataType || this.config.dataType }; return new Promise((resolve, reject) => { const entity = { config, resolve, reject, response: null }; this.queue.push(entity); this.next(); }); } head(url, data, header, dataType) { return this.request('HEAD', url, data, header, dataType); } options(url, data, header, dataType) { return this.request('OPTIONS', url, data, header, dataType); } get(url, data, header, dataType) { return this.request('GET', url, data, header, dataType); } post(url, data, header, dataType) { return this.request('POST', url, data, header, dataType); } put(url, data, header, dataType) { return this.request('PUT', url, data, header, dataType); } ['delete'](url, data, header, dataType) { return this.request('DELETE', url, data, header, dataType); } trace(url, data, header, dataType) { return this.request('TRACE', url, data, header, dataType); } connect(url, data, header, dataType) { return this.request('CONNECT', url, data, header, dataType); } setRequestInterceptor(interceptor) { this.requestInterceptor = interceptor; return this; } setResponseInterceptor(interceptor) { this.responseInterceptor = interceptor; return this; } clean() { this.queue = []; } } export default new Http(); [代码] 兼容 fetch 标准 vendor/fetch.js [代码]import http from './http'; const httpClient = http.create({ maxConcurrent: 10, timeout: 0, header: {}, dataType: 'json' }); function generateResponse(res) { let header = res.header || {}; let config = res.config || {}; return { ok: ((res.statusCode / 200) | 0) === 1, // 200-299 status: res.statusCode, statusText: res.errMsg, url: config.url, clone: () => generateResponse(res), text: () => Promise.resolve( typeof res.data === 'string' ? res.data : JSON.stringify(res.data) ), json: () => { if (typeof res.data === 'object') return Promise.resolve(res.data); let json = {}; try { json = JSON.parse(res.data); } catch (err) { console.error(err); } return json; }, blob: () => Promise.resolve(new Blob([res.data])), headers: { keys: () => Object.keys(header), entries: () => { let all = []; for (let key in header) { if (header.hasOwnProperty(key)) { all.push([key, header[key]]); } } return all; }, get: n => header[n.toLowerCase()], has: n => n.toLowerCase() in header } }; } export default (typeof fetch === 'function' ? fetch.bind() : function(url, options) { options = options || {}; return httpClient .request(options.method || 'get', url, options.body, options.headers) .then(res => Promise.resolve(generateResponse(res))) .catch(res => Promise.reject(generateResponse(res))); }); [代码] GraphQL客户端 vendor/fetchql.js [代码]import fetch from './fetch'; // https://github.com/gucheen/fetchql /** Class to realize fetch interceptors */ class FetchInterceptor { constructor() { this.interceptors = []; /* global fetch */ this.fetch = (...args) => this.interceptorWrapper(fetch, ...args); } /** * add new interceptors * @param {(Object|Object[])} interceptors */ addInterceptors(interceptors) { const removeIndex = []; if (Array.isArray(interceptors)) { interceptors.map((interceptor) => { removeIndex.push(this.interceptors.length); return this.interceptors.push(interceptor); }); } else if (interceptors instanceof Object) { removeIndex.push(this.interceptors.length); this.interceptors.push(interceptors); } this.updateInterceptors(); return () => this.removeInterceptors(removeIndex); } /** * remove interceptors by indexes * @param {number[]} indexes */ removeInterceptors(indexes) { if (Array.isArray(indexes)) { indexes.map(index => this.interceptors.splice(index, 1)); this.updateInterceptors(); } } /** * @private */ updateInterceptors() { this.reversedInterceptors = this.interceptors .reduce((array, interceptor) => [interceptor].concat(array), []); } /** * remove all interceptors */ clearInterceptors() { this.interceptors = []; this.updateInterceptors(); } /** * @private */ interceptorWrapper(fetch, ...args) { let promise = Promise.resolve(args); this.reversedInterceptors.forEach(({ request, requestError }) => { if (request || requestError) { promise = promise.then(() => request(...args), requestError); } }); promise = promise.then(() => fetch(...args)); this.reversedInterceptors.forEach(({ response, responseError }) => { if (response || responseError) { promise = promise.then(response, responseError); } }); return promise; } } /** * GraphQL client with fetch api. * @extends FetchInterceptor */ class FetchQL extends FetchInterceptor { /** * Create a FetchQL instance. * @param {Object} options * @param {String} options.url - the server address of GraphQL * @param {(Object|Object[])=} options.interceptors * @param {{}=} options.headers - request headers * @param {FetchQL~requestQueueChanged=} options.onStart - callback function of a new request queue * @param {FetchQL~requestQueueChanged=} options.onEnd - callback function of request queue finished * @param {Boolean=} options.omitEmptyVariables - remove null props(null or '') from the variables * @param {Object=} options.requestOptions - addition options to fetch request(refer to fetch api) */ constructor({ url, interceptors, headers, onStart, onEnd, omitEmptyVariables = false, requestOptions = {}, }) { super(); this.requestObject = Object.assign( {}, { method: 'POST', headers: Object.assign({}, { Accept: 'application/json', 'Content-Type': 'application/json', }, headers), credentials: 'same-origin', }, requestOptions, ); this.url = url; this.omitEmptyVariables = omitEmptyVariables; // marker for request queue this.requestQueueLength = 0; // using for caching enums' type this.EnumMap = {}; this.callbacks = { onStart, onEnd, }; this.addInterceptors(interceptors); } /** * operate a query * @param {Object} options * @param {String} options.operationName * @param {String} options.query * @param {Object=} options.variables * @param {Object=} options.opts - addition options(will not be passed to server) * @param {Boolean=} options.opts.omitEmptyVariables - remove null props(null or '') from the variables * @param {Object=} options.requestOptions - addition options to fetch request(refer to fetch api) * @returns {Promise} * @memberOf FetchQL */ query({ operationName, query, variables, opts = {}, requestOptions = {}, }) { const options = Object.assign({}, this.requestObject, requestOptions); let vars; if (this.omitEmptyVariables || opts.omitEmptyVariables) { vars = this.doOmitEmptyVariables(variables); } else { vars = variables; } const body = { operationName, query, variables: vars, }; options.body = JSON.stringify(body); this.onStart(); return this.fetch(this.url, options) .then((res) => { if (res.ok) { return res.json(); } // return an custom error stack if request error return { errors: [{ message: res.statusText, stack: res, }], }; }) .then(({ data, errors }) => ( new Promise((resolve, reject) => { this.onEnd(); // if data in response is 'null' if (!data) { return reject(errors || [{}]); } // if all properties of data is 'null' const allDataKeyEmpty = Object.keys(data).every(key => !data[key]); if (allDataKeyEmpty) { return reject(errors); } return resolve({ data, errors }); }) )); } /** * get current server address * @returns {String} * @memberOf FetchQL */ getUrl() { return this.url; } /** * setting a new server address * @param {String} url * @memberOf FetchQL */ setUrl(url) { this.url = url; } /** * get information of enum type * @param {String[]} EnumNameList - array of enums' name * @returns {Promise} * @memberOf FetchQL */ getEnumTypes(EnumNameList) { const fullData = {}; // check cache status const unCachedEnumList = EnumNameList.filter((element) => { if (this.EnumMap[element]) { // enum has been cached fullData[element] = this.EnumMap[element]; return false; } return true; }); // immediately return the data if all enums have been cached if (!unCachedEnumList.length) { return new Promise((resolve) => { resolve({ data: fullData }); }); } // build query string for uncached enums const EnumTypeQuery = unCachedEnumList.map(type => ( `${type}: __type(name: "${type}") { ...EnumFragment }` )); const query = ` query { ${EnumTypeQuery.join('\n')} } fragment EnumFragment on __Type { kind description enumValues { name description } }`; const options = Object.assign({}, this.requestObject); options.body = JSON.stringify({ query }); this.onStart(); return this.fetch(this.url, options) .then((res) => { if (res.ok) { return res.json(); } // return an custom error stack if request error return { errors: [{ message: res.statusText, stack: res, }], }; }) .then(({ data, errors }) => ( new Promise((resolve, reject) => { this.onEnd(); // if data in response is 'null' and have any errors if (!data) { return reject(errors || [{ message: 'Do not get any data.' }]); } // if all properties of data is 'null' const allDataKeyEmpty = Object.keys(data).every(key => !data[key]); if (allDataKeyEmpty && errors && errors.length) { return reject(errors); } // merge enums' data const passData = Object.assign(fullData, data); // cache new enums' data Object.keys(data).map((key) => { this.EnumMap[key] = data[key]; return key; }); return resolve({ data: passData, errors }); }) )); } /** * calling on a request starting * if the request belong to a new queue, call the 'onStart' method */ onStart() { this.requestQueueLength++; if (this.requestQueueLength > 1 || !this.callbacks.onStart) { return; } this.callbacks.onStart(this.requestQueueLength); } /** * calling on a request ending * if current queue finished, calling the 'onEnd' method */ onEnd() { this.requestQueueLength--; if (this.requestQueueLength || !this.callbacks.onEnd) { return; } this.callbacks.onEnd(this.requestQueueLength); } /** * Callback of requests queue changes.(e.g. new queue or queue finished) * @callback FetchQL~requestQueueChanged * @param {number} queueLength - length of current request queue */ /** * remove empty props(null or '') from object * @param {Object} input * @returns {Object} * @memberOf FetchQL * @private */ doOmitEmptyVariables(input) { const nonEmptyObj = {}; Object.keys(input).map(key => { const value = input[key]; if ((typeof value === 'string' && value.length === 0) || value === null || value === undefined) { return key; } else if (value instanceof Object) { nonEmptyObj[key] = this.doOmitEmptyVariables(value); } else { nonEmptyObj[key] = value; } return key; }); return nonEmptyObj; } } export default FetchQL; [代码] 将wx的异步接口封装成Promise vendor/promisify.js [代码]function promisify(wx) { let wxx = { ...wx }; for (let attr in wxx) { if (!wxx.hasOwnProperty(attr) || typeof wxx[attr] != 'function') continue; // skip over the sync method if (/sync$/i.test(attr)) continue; wxx[attr + 'Async'] = function asyncFunction(argv = {}) { return new Promise(function (resolve, reject) { wxx[attr].call(wxx, { ...argv, ...{ success: res => resolve(res), fail: err => reject(err) } }); }); }; } return wxx; } export default promisify(typeof wx === 'object' ? wx : {}); [代码] localstorage vendor/storage.js [代码]class Storage { constructor(wx) { this.wx = wx; } static get timestamp() { return new Date() / 1000; } static __isExpired(entity) { if (!entity) return true; return Storage.timestamp - (entity.timestamp + entity.expiration) >= 0; } static get __info() { let info = {}; try { info = this.wx.getStorageInfoSync() || info; } catch (err) { console.error(err); } return info; } setItem(key, value, expiration) { const entity = { timestamp: Storage.timestamp, expiration, key, value }; this.wx.setStorageSync(key, JSON.stringify(entity)); return this; } getItem(key) { let entity; try { entity = this.wx.getStorageSync(key); if (entity) { entity = JSON.parse(entity); } else { return null; } } catch (err) { console.error(err); return null; } // 没有设置过期时间, 则直接返回值 if (!entity.expiration) return entity.value; // 已过期 if (Storage.__isExpired(entity)) { this.remove(key); return null; } else { return entity.value; } } removeItem(key) { try { this.wx.removeStorageSync(key); } catch (err) { console.error(err); } return this; } clear() { try { this.wx.clearStorageSync(); } catch (err) { console.error(err); } return this; } get info() { let info = {}; try { info = this.wx.getStorageInfoSync(); } catch (err) { console.error(err); } return info || {}; } get length() { return (this.info.keys || []).length; } } export default new Storage(wx); [代码] 状态管理 vendor/store.js [代码]module.exports = class Store { constructor({ state, actions }) { this.state = state || {} this.actions = actions || {} this.ctxs = [] } // 派发action, 统一返回promise action可以直接返回state dispatch(type, payload) { const update = res => { if (typeof res !== 'object') return this.setState(res) this.ctxs.map(ctx => ctx.setData(res)) return res } if (typeof this.actions[type] !== 'function') return const res = this.actions[type](this, payload) return res.constructor.toString().match(/function\s*([^(]*)/)[1] === 'Promise' ? res.then(update) : new Promise(resolve => resolve(update(res))) } // 修改state的方法 setState(data) { this.state = { ...this.state, ...data } } // 根据keys获取state getState(keys) { return keys.reduce((acc, key) => ({ ...acc, ...{ [key]: this.state[key] } }), {}) } // 映射state到实例中,可在onload或onshow中调用 mapState(keys, ctx) { if (!ctx || typeof ctx.setData !== 'function') return ctx.setData(this.getState(keys)) this.ctxs.push(ctx) } } [代码] 兼容 async/await vendor/regenerator.js [代码]/** * Copyright (c) 2014-present, Facebook, Inc. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ var regeneratorRuntime = (function (exports) { "use strict"; var Op = Object.prototype; var hasOwn = Op.hasOwnProperty; var undefined; // More compressible than void 0. var $Symbol = typeof Symbol === "function" ? Symbol : {}; var iteratorSymbol = $Symbol.iterator || "@@iterator"; var asyncIteratorSymbol = $Symbol.asyncIterator || "@@asyncIterator"; var toStringTagSymbol = $Symbol.toStringTag || "@@toStringTag"; function wrap(innerFn, outerFn, self, tryLocsList) { // If outerFn provided and outerFn.prototype is a Generator, then outerFn.prototype instanceof Generator. var protoGenerator = outerFn && outerFn.prototype instanceof Generator ? outerFn : Generator; var generator = Object.create(protoGenerator.prototype); var context = new Context(tryLocsList || []); // The ._invoke method unifies the implementations of the .next, // .throw, and .return methods. generator._invoke = makeInvokeMethod(innerFn, self, context); return generator; } exports.wrap = wrap; // Try/catch helper to minimize deoptimizations. Returns a completion // record like context.tryEntries[i].completion. This interface could // have been (and was previously) designed to take a closure to be // invoked without arguments, but in all the cases we care about we // already have an existing method we want to call, so there's no need // to create a new function object. We can even get away with assuming // the method takes exactly one argument, since that happens to be true // in every case, so we don't have to touch the arguments object. The // only additional allocation required is the completion record, which // has a stable shape and so hopefully should be cheap to allocate. function tryCatch(fn, obj, arg) { try { return { type: "normal", arg: fn.call(obj, arg) }; } catch (err) { return { type: "throw", arg: err }; } } var GenStateSuspendedStart = "suspendedStart"; var GenStateSuspendedYield = "suspendedYield"; var GenStateExecuting = "executing"; var GenStateCompleted = "completed"; // Returning this object from the innerFn has the same effect as // breaking out of the dispatch switch statement. var ContinueSentinel = {}; // Dummy constructor functions that we use as the .constructor and // .constructor.prototype properties for functions that return Generator // objects. For full spec compliance, you may wish to configure your // minifier not to mangle the names of these two functions. function Generator() {} function GeneratorFunction() {} function GeneratorFunctionPrototype() {} // This is a polyfill for %IteratorPrototype% for environments that // don't natively support it. var IteratorPrototype = {}; IteratorPrototype[iteratorSymbol] = function () { return this; }; var getProto = Object.getPrototypeOf; var NativeIteratorPrototype = getProto && getProto(getProto(values([]))); if (NativeIteratorPrototype && NativeIteratorPrototype !== Op && hasOwn.call(NativeIteratorPrototype, iteratorSymbol)) { // This environment has a native %IteratorPrototype%; use it instead // of the polyfill. IteratorPrototype = NativeIteratorPrototype; } var Gp = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(IteratorPrototype); GeneratorFunction.prototype = Gp.constructor = GeneratorFunctionPrototype; GeneratorFunctionPrototype.constructor = GeneratorFunction; GeneratorFunctionPrototype[toStringTagSymbol] = GeneratorFunction.displayName = "GeneratorFunction"; // Helper for defining the .next, .throw, and .return methods of the // Iterator interface in terms of a single ._invoke method. function defineIteratorMethods(prototype) { ["next", "throw", "return"].forEach(function(method) { prototype[method] = function(arg) { return this._invoke(method, arg); }; }); } exports.isGeneratorFunction = function(genFun) { var ctor = typeof genFun === "function" && genFun.constructor; return ctor ? ctor === GeneratorFunction || // For the native GeneratorFunction constructor, the best we can // do is to check its .name property. (ctor.displayName || ctor.name) === "GeneratorFunction" : false; }; exports.mark = function(genFun) { if (Object.setPrototypeOf) { Object.setPrototypeOf(genFun, GeneratorFunctionPrototype); } else { genFun.__proto__ = GeneratorFunctionPrototype; if (!(toStringTagSymbol in genFun)) { genFun[toStringTagSymbol] = "GeneratorFunction"; } } genFun.prototype = Object.create(Gp); return genFun; }; // Within the body of any async function, `await x` is transformed to // `yield regeneratorRuntime.awrap(x)`, so that the runtime can test // `hasOwn.call(value, "__await")` to determine if the yielded value is // meant to be awaited. exports.awrap = function(arg) { return { __await: arg }; }; function AsyncIterator(generator) { function invoke(method, arg, resolve, reject) { var record = tryCatch(generator[method], generator, arg); if (record.type === "throw") { reject(record.arg); } else { var result = record.arg; var value = result.value; if (value && typeof value === "object" && hasOwn.call(value, "__await")) { return Promise.resolve(value.__await).then(function(value) { invoke("next", value, resolve, reject); }, function(err) { invoke("throw", err, resolve, reject); }); } return Promise.resolve(value).then(function(unwrapped) { // When a yielded Promise is resolved, its final value becomes // the .value of the Promise<{value,done}> result for the // current iteration. result.value = unwrapped; resolve(result); }, function(error) { // If a rejected Promise was yielded, throw the rejection back // into the async generator function so it can be handled there. return invoke("throw", error, resolve, reject); }); } } var previousPromise; function enqueue(method, arg) { function callInvokeWithMethodAndArg() { return new Promise(function(resolve, reject) { invoke(method, arg, resolve, reject); }); } return previousPromise = // If enqueue has been called before, then we want to wait until // all previous Promises have been resolved before calling invoke, // so that results are always delivered in the correct order. If // enqueue has not been called before, then it is important to // call invoke immediately, without waiting on a callback to fire, // so that the async generator function has the opportunity to do // any necessary setup in a predictable way. This predictability // is why the Promise constructor synchronously invokes its // executor callback, and why async functions synchronously // execute code before the first await. Since we implement simple // async functions in terms of async generators, it is especially // important to get this right, even though it requires care. previousPromise ? previousPromise.then( callInvokeWithMethodAndArg, // Avoid propagating failures to Promises returned by later // invocations of the iterator. callInvokeWithMethodAndArg ) : callInvokeWithMethodAndArg(); } // Define the unified helper method that is used to implement .next, // .throw, and .return (see defineIteratorMethods). this._invoke = enqueue; } defineIteratorMethods(AsyncIterator.prototype); AsyncIterator.prototype[asyncIteratorSymbol] = function () { return this; }; exports.AsyncIterator = AsyncIterator; // Note that simple async functions are implemented on top of // AsyncIterator objects; they just return a Promise for the value of // the final result produced by the iterator. exports.async = function(innerFn, outerFn, self, tryLocsList) { var iter = new AsyncIterator( wrap(innerFn, outerFn, self, tryLocsList) ); return exports.isGeneratorFunction(outerFn) ? iter // If outerFn is a generator, return the full iterator. : iter.next().then(function(result) { return result.done ? result.value : iter.next(); }); }; function makeInvokeMethod(innerFn, self, context) { var state = GenStateSuspendedStart; return function invoke(method, arg) { if (state === GenStateExecuting) { throw new Error("Generator is already running"); } if (state === GenStateCompleted) { if (method === "throw") { throw arg; } // Be forgiving, per 25.3.3.3.3 of the spec: // https://people.mozilla.org/~jorendorff/es6-draft.html#sec-generatorresume return doneResult(); } context.method = method; context.arg = arg; while (true) { var delegate = context.delegate; if (delegate) { var delegateResult = maybeInvokeDelegate(delegate, context); if (delegateResult) { if (delegateResult === ContinueSentinel) continue; return delegateResult; } } if (context.method === "next") { // Setting context._sent for legacy support of Babel's // function.sent implementation. context.sent = context._sent = context.arg; } else if (context.method === "throw") { if (state === GenStateSuspendedStart) { state = GenStateCompleted; throw context.arg; } context.dispatchException(context.arg); } else if (context.method === "return") { context.abrupt("return", context.arg); } state = GenStateExecuting; var record = tryCatch(innerFn, self, context); if (record.type === "normal") { // If an exception is thrown from innerFn, we leave state === // GenStateExecuting and loop back for another invocation. state = context.done ? GenStateCompleted : GenStateSuspendedYield; if (record.arg === ContinueSentinel) { continue; } return { value: record.arg, done: context.done }; } else if (record.type === "throw") { state = GenStateCompleted; // Dispatch the exception by looping back around to the // context.dispatchException(context.arg) call above. context.method = "throw"; context.arg = record.arg; } } }; } // Call delegate.iterator[context.method](context.arg) and handle the // result, either by returning a { value, done } result from the // delegate iterator, or by modifying context.method and context.arg, // setting context.delegate to null, and returning the ContinueSentinel. function maybeInvokeDelegate(delegate, context) { var method = delegate.iterator[context.method]; if (method === undefined) { // A .throw or .return when the delegate iterator has no .throw // method always terminates the yield* loop. context.delegate = null; if (context.method === "throw") { // Note: ["return"] must be used for ES3 parsing compatibility. if (delegate.iterator["return"]) { // If the delegate iterator has a return method, give it a // chance to clean up. context.method = "return"; context.arg = undefined; maybeInvokeDelegate(delegate, context); if (context.method === "throw") { // If maybeInvokeDelegate(context) changed context.method from // "return" to "throw", let that override the TypeError below. return ContinueSentinel; } } context.method = "throw"; context.arg = new TypeError( "The iterator does not provide a 'throw' method"); } return ContinueSentinel; } var record = tryCatch(method, delegate.iterator, context.arg); if (record.type === "throw") { context.method = "throw"; context.arg = record.arg; context.delegate = null; return ContinueSentinel; } var info = record.arg; if (! info) { context.method = "throw"; context.arg = new TypeError("iterator result is not an object"); context.delegate = null; return ContinueSentinel; } if (info.done) { // Assign the result of the finished delegate to the temporary // variable specified by delegate.resultName (see delegateYield). context[delegate.resultName] = info.value; // Resume execution at the desired location (see delegateYield). context.next = delegate.nextLoc; // If context.method was "throw" but the delegate handled the // exception, let the outer generator proceed normally. If // context.method was "next", forget context.arg since it has been // "consumed" by the delegate iterator. If context.method was // "return", allow the original .return call to continue in the // outer generator. if (context.method !== "return") { context.method = "next"; context.arg = undefined; } } else { // Re-yield the result returned by the delegate method. return info; } // The delegate iterator is finished, so forget it and continue with // the outer generator. context.delegate = null; return ContinueSentinel; } // Define Generator.prototype.{next,throw,return} in terms of the // unified ._invoke helper method. defineIteratorMethods(Gp); Gp[toStringTagSymbol] = "Generator"; // A Generator should always return itself as the iterator object when the // @@iterator function is called on it. Some browsers' implementations of the // iterator prototype chain incorrectly implement this, causing the Generator // object to not be returned from this call. This ensures that doesn't happen. // See https://github.com/facebook/regenerator/issues/274 for more details. Gp[iteratorSymbol] = function() { return this; }; Gp.toString = function() { return "[object Generator]"; }; function pushTryEntry(locs) { var entry = { tryLoc: locs[0] }; if (1 in locs) { entry.catchLoc = locs[1]; } if (2 in locs) { entry.finallyLoc = locs[2]; entry.afterLoc = locs[3]; } this.tryEntries.push(entry); } function resetTryEntry(entry) { var record = entry.completion || {}; record.type = "normal"; delete record.arg; entry.completion = record; } function Context(tryLocsList) { // The root entry object (effectively a try statement without a catch // or a finally block) gives us a place to store values thrown from // locations where there is no enclosing try statement. this.tryEntries = [{ tryLoc: "root" }]; tryLocsList.forEach(pushTryEntry, this); this.reset(true); } exports.keys = function(object) { var keys = []; for (var key in object) { keys.push(key); } keys.reverse(); // Rather than returning an object with a next method, we keep // things simple and return the next function itself. return function next() { while (keys.length) { var key = keys.pop(); if (key in object) { next.value = key; next.done = false; return next; } } // To avoid creating an additional object, we just hang the .value // and .done properties off the next function object itself. This // also ensures that the minifier will not anonymize the function. next.done = true; return next; }; }; function values(iterable) { if (iterable) { var iteratorMethod = iterable[iteratorSymbol]; if (iteratorMethod) { return iteratorMethod.call(iterable); } if (typeof iterable.next === "function") { return iterable; } if (!isNaN(iterable.length)) { var i = -1, next = function next() { while (++i < iterable.length) { if (hasOwn.call(iterable, i)) { next.value = iterable[i]; next.done = false; return next; } } next.value = undefined; next.done = true; return next; }; return next.next = next; } } // Return an iterator with no values. return { next: doneResult }; } exports.values = values; function doneResult() { return { value: undefined, done: true }; } Context.prototype = { constructor: Context, reset: function(skipTempReset) { this.prev = 0; this.next = 0; // Resetting context._sent for legacy support of Babel's // function.sent implementation. this.sent = this._sent = undefined; this.done = false; this.delegate = null; this.method = "next"; this.arg = undefined; this.tryEntries.forEach(resetTryEntry); if (!skipTempReset) { for (var name in this) { // Not sure about the optimal order of these conditions: if (name.charAt(0) === "t" && hasOwn.call(this, name) && !isNaN(+name.slice(1))) { this[name] = undefined; } } } }, stop: function() { this.done = true; var rootEntry = this.tryEntries[0]; var rootRecord = rootEntry.completion; if (rootRecord.type === "throw") { throw rootRecord.arg; } return this.rval; }, dispatchException: function(exception) { if (this.done) { throw exception; } var context = this; function handle(loc, caught) { record.type = "throw"; record.arg = exception; context.next = loc; if (caught) { // If the dispatched exception was caught by a catch block, // then let that catch block handle the exception normally. context.method = "next"; context.arg = undefined; } return !! caught; } for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; var record = entry.completion; if (entry.tryLoc === "root") { // Exception thrown outside of any try block that could handle // it, so set the completion value of the entire function to // throw the exception. return handle("end"); } if (entry.tryLoc <= this.prev) { var hasCatch = hasOwn.call(entry, "catchLoc"); var hasFinally = hasOwn.call(entry, "finallyLoc"); if (hasCatch && hasFinally) { if (this.prev < entry.catchLoc) { return handle(entry.catchLoc, true); } else if (this.prev < entry.finallyLoc) { return handle(entry.finallyLoc); } } else if (hasCatch) { if (this.prev < entry.catchLoc) { return handle(entry.catchLoc, true); } } else if (hasFinally) { if (this.prev < entry.finallyLoc) { return handle(entry.finallyLoc); } } else { throw new Error("try statement without catch or finally"); } } } }, abrupt: function(type, arg) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.tryLoc <= this.prev && hasOwn.call(entry, "finallyLoc") && this.prev < entry.finallyLoc) { var finallyEntry = entry; break; } } if (finallyEntry && (type === "break" || type === "continue") && finallyEntry.tryLoc <= arg && arg <= finallyEntry.finallyLoc) { // Ignore the finally entry if control is not jumping to a // location outside the try/catch block. finallyEntry = null; } var record = finallyEntry ? finallyEntry.completion : {}; record.type = type; record.arg = arg; if (finallyEntry) { this.method = "next"; this.next = finallyEntry.finallyLoc; return ContinueSentinel; } return this.complete(record); }, complete: function(record, afterLoc) { if (record.type === "throw") { throw record.arg; } if (record.type === "break" || record.type === "continue") { this.next = record.arg; } else if (record.type === "return") { this.rval = this.arg = record.arg; this.method = "return"; this.next = "end"; } else if (record.type === "normal" && afterLoc) { this.next = afterLoc; } return ContinueSentinel; }, finish: function(finallyLoc) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.finallyLoc === finallyLoc) { this.complete(entry.completion, entry.afterLoc); resetTryEntry(entry); return ContinueSentinel; } } }, "catch": function(tryLoc) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.tryLoc === tryLoc) { var record = entry.completion; if (record.type === "throw") { var thrown = record.arg; resetTryEntry(entry); } return thrown; } } // The context.catch method must only be called with a location // argument that corresponds to a known catch block. throw new Error("illegal catch attempt"); }, delegateYield: function(iterable, resultName, nextLoc) { this.delegate = { iterator: values(iterable), resultName: resultName, nextLoc: nextLoc }; if (this.method === "next") { // Deliberately forget the last sent value so that we don't // accidentally pass it on to the delegate. this.arg = undefined; } return ContinueSentinel; } }; // Regardless of whether this script is executing as a CommonJS module // or not, return the runtime object so that we can declare the variable // regeneratorRuntime in the outer scope, which allows this module to be // injected easily by `bin/regenerator --include-runtime script.js`. return exports; }( // If this script is executing as a CommonJS module, use module.exports // as the regeneratorRuntime namespace. Otherwise create a new empty // object. Either way, the resulting object will be used to initialize // the regeneratorRuntime variable at the top of this file. typeof module === "object" ? module.exports : {} )); [代码] 后端 [代码]const typeDefs = gql` # schema 下面是根类型,约定是 RootQuery 和 RootMutation schema { query: Query } # 定义具体的 Query 的结构 type Query { # 登陆接口 login(code: String!, encryptedData: String, iv: String): Login } type Login { token: String! userInfo: UserInfo } type UserInfo { nickName: String gender: String avatarUrl: String } `; const resolvers = { Query: { async login(parent, { code, encryptedData, iv }) { const { sessionKey, openId, unionId } = await wxService.code2Session(code); const userInfo = encryptedData && iv ? wxService.decryptData(sessionKey, encryptedData, iv) : { openId, unionId }; if (userInfo.nickName) { userService.createOrUpdateWxUser(userInfo); } const token = await userService.generateJwtToken(userInfo); return { token, userInfo }; }, }, }; [代码]
2019-04-21 - 微信小程序之自定义模态弹窗(带动画)实例
1、基本需求。 实现用户自定义弹框 带动画(动画可做参靠,个人要是觉得不好看可以自定义动画) 获取弹出框的内容,自定义事件获取 2、案例目录结构 二、程序实现具体步骤 1.弹框index.wxml代码 [图片] <!–button–> <view class=“btn” bindtap=“powerDrawer” data-statu=“open”>来点我呀</view> <!–mask–> <view class=“drawer_screen” bindtap=“powerDrawer” data-statu=“close” wx:if="{{showModalStatus}}"></view> <!–content–> <!–使用animation属性指定需要执行的动画–> <view animation="{{animationData}}" class=“drawer_box” wx:if="{{showModalStatus}}"> <!–drawer content–> <view class=“drawer_title”>弹窗标题</view> <view class=“drawer_content”> <view class=“top grid”> <label class=“title col-0”>标题</label> <input class=“input_base input_h30 col-1” name=“rName” value=“可自行定义内容”></input> </view> <view class=“top grid”> <label class=“title col-0”>标题</label> <input class=“input_base input_h30 col-1” name=“mobile” value=“110”></input> </view> <view class=“top grid”> <label class=“title col-0”>标题</label> <input class=“input_base input_h30 col-1” name=“phone” value=“拒绝伸手党”></input> </view> <view class=“top grid”> <label class=“title col-0”>标题</label> <input class=“input_base input_h30 col-1” name=“Email” value=“仅供学习使用”></input> </view> <view class=“top bottom grid”> <label class=“title col-0”>备注</label> <input class=“input_base input_h30 col-1” name=“bz”></input> </view> </view> <view class=“btn_ok” bindtap=“powerDrawer” data-statu=“close”>确定</view> </view> 我这边把效果图放出来吧,需要的,自己看下面地址:http://wxapp.662p.com/thread-3713-1-1.html 这是效果图: [图片]
2019-04-12 - 一个通用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 - 微信小程序非跳转式组件授权登录
微信小程序非跳转式组件授权登录 首先附上官方文档地址和授权流程 官方地址:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html 流程图: [图片] 大致逻辑:授权 -> 发送code到服务器获取session_key - > 保存在小程序缓存内 -> 调用wx.getUserInfo和session_key获取用户信息 -> 登录成功返回访问token -> 记录登录状态 -> 执行登录成功监听(失败则不监听) 直接上代码,一下均为小程序组件模式有兴趣的可以看下官方文档 创建components(自定义名称)文件夹pages文件夹同级主要放置组件文件 创建 authorize (自定义名称)文件夹 还是一样的创建 对应的authorize.js ,authorize.wxml .authorize.wxss,authorize.json特别注意这里的 authorize.json 文件里面要定义当前页面为组件 [代码]{ "component": true } [代码] 到这里准备工作完成 authorize.js 换成组件的写法,具体参考小程序官方文档,这里展示我定义的 [代码]Component({ //组件的对外属性 说的确实很官方,用过vue组件的就很容易理解这点 //父级向子级传值这里就是接收值得地方 properties:{ //名称要和父级绑定的名称相同 //这里主要是控制自动授权弹框是否显示 true=隐藏 false=显示 iShidden:{ type:Boolean,//定义类型 value: true,//定义默认值 }, //是否自动登录 这里主要用于没有授权是否自动弹出授权提示框 //**用在不自动登录页面但是某些操作需要授权登录** isAuto:{ type: Boolean, value: true, }, }, //组件的内部数据,和 properties 一同用于组件的模板渲染 data:{ cloneIner:null }, //组件所在页面的生命周期声明对象 pageLifetimes:{ //页面隐藏 hide:function(){ //关闭页面时销毁定时器 if(this.data.cloneIner) clearInterval(this.data.clearInterval); }, //页面打开 show:function(){ //打开页面销毁定时器 if (this.data.cloneIner) clearInterval(this.data.clearInterval); }, }, //组件生命周期函数,在组件实例进入页面节点树时执行 attached(){ }, //组件的方法 methods:{ } }); [代码] 注:以下的方法都需写在 methods 内 第一步:未授权用户判断是否执行授权还是直接进行获取用户信息 [代码]//检测登录状态并执行自动登录 setAuthStatus(){ var that = this; that.setErrorCount(); wx.getSetting({ success(res) { //这里会检测是否授权,如果授权了会直接调用自动登录 if (!res.authSetting['scope.userInfo']) { //没有授权不会自动弹出登录框 if (that.data.isAuto === false) return; //自动弹出授权 that.setData({ iShidden: false }); } else { //自动登录 that.setData({ iShidden: true }); if (app.globalData.token) { //这里是授权回调 that.triggerEvent('onLoadFun', app.globalData.token); that.WatchIsLogin(); } else { wx.showLoading({ title: '正在登录中' }); //这里是已授权调用wx.getUserInfo that.getUserInfoBydecryptCode(); } } } }) } [代码] 第二步,没有授权执行打开授权弹出框 [代码] //授权 setUserInfo(e){ var that = this, pdata={}; pdata.userInfo = e.detail.userInfo; pdata.spid = app.globalData.spid; wx.showLoading({ title: '正在登录中' }); wx.login({ success: function (res) { if (!res.code) return app.Tips({ title: '登录失败!' + res.errMsg}); //获取session_key并缓存 that.getSessionKey(res.code, function () { that.getUserInfoBydecryptCode(); }); }, fail() { wx.hideLoading(); } }) }, //从缓存中获取session_key,如果没有则请求服务器再次缓存 getSessionKey(code,successFn,errotFn){ var that=this; wx.checkSession({ success: function (res){ if(wx.getStorageSync('session_key')) successFn && successFn(); else that.setCode(code, successFn, errotFn); }, fail:function(){ that.setCode(code, successFn, errotFn); } }); }, //访问服务器获得session_key 并存入缓存中 setCode(code, successFn, errotFn){ var that = this; app.basePost(app.U({ c: 'Login', a: 'setCode' }), { code: code }, function (res) { wx.setStorageSync('session_key', res.data.session_key); successFn && successFn(res); }, function (res) { if (errotFn) errotFn(res); else return app.Tips({ title: '获取session_key失败' }); }); } [代码] 第三步:执行getUserInfoBydecryptCode 登录获取访问权限 [代码] getUserInfoBydecryptCode: function () { var that = this; var session_key = wx.getStorageSync('session_key') //没有获取到session_key,打开授权页面 //这里必须的判断存在缓存中的session_key是否存在,因为在第一步的时候,判断了 //授权了将自动执行获取用户信息的方法 if (!session_key) { wx.hideLoading(); if(that.data.isAuto) that.setData({ iShidden: false }) return false; }; wx.getUserInfo({ lang: 'zh_CN', success: function (res) { var pdata = res; pdata.userInfo = res.userInfo; pdata.spid = app.globalData.spid;//获取推广人ID pdata.code = app.globalData.code;//获取推广人分享二维码ID if (res.iv) { pdata.iv = encodeURI(res.iv); pdata.encryptedData = res.encryptedData; pdata.session_key = session_key; //获取用户信息生成访问token app.basePost(app.U({ c: 'login', a: 'index' }), { info: pdata},function(res){ if (res.data.status == 0) return app.Tips( { title: '抱歉,您已被禁止登录!' }, { tab: 4, url: '/pages/login-status/login-status' } ); else if(res.data.status==410){ wx.removeStorage({ key:'session_key'}); wx.hideLoading(); if (that.data.iShidden == true) that.setData({ iShidden: false }); return false; } //取消登录提示 wx.hideLoading(); //关闭登录弹出窗口 that.setData({ iShidden: true, ErrorCount:0}); //保存token和记录登录状态 app.globalData.token = res.data.token; app.globalData.isLog = true; //执行登录完成回调 that.triggerEvent('onLoadFun', app.globalData.uid); //监听登录状态 that.WatchIsLogin(); },function(res){ wx.hideLoading(); return app.Tips({title:res.msg}); }); } else { wx.hideLoading(); return app.Tips({ title: '用户信息获取失败!'}); } }, fail: function () { wx.hideLoading(); that.setData({ iShidden: false }); }, }) } [代码] 第四步:监听登录状态 再服务器无法获取到token时,当前页面会一直监听token是否为空,防止无限获取token设置错误次数,终止监听 监听token的用意为:token是服务器返回当前用户的访问凭证,凭证有过期的时候这时候所有的网络请求将无法访问,所以用了一个愚蠢的方法来监听token [代码] //监听登录状态 WatchIsLogin:function(){ this.data.cloneIner=setInterval(function(){ //防止死循环,超过错误次数终止监听 if (this.getErrorCount()) return clearInterval(this.data.clearInterval); if (app.globalData.token == '') this.setAuthStatus(); }.bind(this),800); this.setData({ cloneIner:this.data.cloneIner}); } /** * 处理错误次数,防止死循环 * */ setErrorCount:function(){ if (!this.data.ErrorCount) this.data.ErrorCount=1; else this.data.ErrorCount++; this.setData({ ErrorCount: this.data.ErrorCount}); }, /** * 获取错误次数,是否终止监听 * */ getErrorCount:function(){ return this.data.ErrorCount >= 10 ? true : false; } [代码] 以上就是组件内全部的方法需要在组件生命周期函数内调用第一步的方法检测授权,执行登录 [代码] attached(){ this.setAuthStatus(); } [代码] 注:在网络请求中一定要处理token失效的操作,主要把 app.globalData.token和app.globalData.isLog 设置回空和false 这里附上没有定义的一些app内公用的快捷方法以下的方法最好是写在其他文件里面在app.js里面写一个快捷调用的方法 [代码]/* * post网络请求 * @param string | object 请求地址 * @param object data POST请求数组 * @param callable successCallback 成功执行方法 * @param callable errorCallback 失败执行方法 */ const basePost = function (url, data, successCallback, errorCallback, header) { if (typeof url == 'object') url = U(url); wx.request({ url: url, data: data, dataType : 'json', method: 'POST', header: header, success: function (res) { try{ if (res.data.code == 200) { successCallback && successCallback(res.data); } else { if (res.data.code == 402) getApp().globalData.token = '', getApp().globalData.isLog = false; //返回状态为401时,用户被禁止访问 关闭当前所有页面跳转至用户禁止登录页面 if (res.data.code == 401) return Tips({ title: res.data.msg}, { tab: 4, url:'/pages/login-status/login-status'}); errorCallback && errorCallback(res.data); } } catch (e) { console.log(e); } }, fail: function (res) { errorCallback && errorCallback(res); }, complete: function (res) { } }); } /* * 组装URl *@param object opt */ const U = function (opt, url) { var m = opt.m || 'routine_two', c = opt.c || 'auth_api', a = opt.a || 'index', q = opt.q || '', p = opt.p || {}, params = '', gets = ''; if (url == undefined) url=getApp().globalData.url; params = Object.keys(p).map(function (key) { return key + '/' + p[key]; }).join('/'); gets = Object.keys(q).map(function (key) { return key + '=' + q[key]; }).join('&'); return url + '/' + m + '/' + c + '/' + a + (params == '' ? '' : '/' + params) +'.html'+ (gets == '' ? '' : '?' + gets); } /* * 组装URl *@param object opt */ const U = function (opt, url) { var m = opt.m || 'routine_two', c = opt.c || 'auth_api', a = opt.a || 'index', q = opt.q || '', p = opt.p || {}, params = '', gets = ''; if (url == undefined) url=getApp().globalData.url; params = Object.keys(p).map(function (key) { return key + '/' + p[key]; }).join('/'); gets = Object.keys(q).map(function (key) { return key + '=' + q[key]; }).join('&'); return url + '/' + m + '/' + c + '/' + a + (params == '' ? '' : '/' + params) +'.html'+ (gets == '' ? '' : '?' + gets); } [代码] 代码量有点多,都是能用到的,望大神们多多指点 本小程序后台框架由 http://github.crmeb.net/u/blue 提供 TP5+EasyWeChat技术支持 如果对微信小程序授权不熟悉的可以用 EasyWeChat,确实好用;不是来吹这个EasyWeChat来了,只是个人觉得好用勿喷
2019-05-07 - 小程序自定义组件知多少
自定义组件 why 代码的复用 在起初小程序只支持 Page 的时候,就会有这样蛋疼的问题:多个页面有相同的组件,每个页面都要复制粘贴一遍,每次改动都要全局搜索一遍,还说不准哪里改漏了就出翔了。 组件化设计 在前端项目中,组件化是很常见的方式,某块通用能力的抽象和设计,是一个必备的技能。组件的管理、数据的管理、应用状态的管理,这些在我们设计的过程中都是需要去思考的。当然你也可以说我就堆代码就好了,不过一个真正的码农是不允许自己这么随便的! 所以,组件化是现代前端必须掌握的生存技能! 自定义组件的实现 一切都从 Virtual DOM 说起 前面《解剖小程序的 setData》有讲过,基于小程序的双线程设计,视图层(Webview 线程)和逻辑层(JS 线程)之间通信(表现为 setData),是基于虚拟 DOM 来实现数据通信和模版更新的。 自定义组件一样的双线程,所以一样滴基于 Virtual DOM 来实现通信。那在这里,Virtual DOM 的一些基本知识(包括生成 VD 对象、Diff 更新等),就不过多介绍啦~ Shadow DOM 模型 基于 Virtual DOM,我们知道在这样的设计里,需要一个框架来支撑维护整个页面的节点树相关信息,包括节点的属性、事件绑定等。在小程序里,Exparser 承担了这个角色。 前面《关于小程序的基础库》也讲过,Exparser 的主要特点包括: 基于 Shadow DOM 模型 可在纯 JS 环境中运行 Shadow DOM 是什么呢,它就是我们在写代码时候写的自定义组件、内置组件、原生组件等。Shadow DOM 为 Web 组件中的 DOM 和 CSS 提供了封装。Shadow DOM 使得这些东西与主文档的 DOM 保持分离。 简而言之,Shadow DOM 是一个 HTML 的新规范,其允许开发者封装 HTML 组件(类似 vue 组件,将 html,css,js 独立部分提取)。 例如我们定义了一个自定义组件叫[代码]<my-component>[代码],你在开发者工具可以见到: [图片] [代码]#shadow-root[代码]称为影子根,DOM 子树的根节点,和文档的主要 DOM 树分开渲染。可以看到它在[代码]<my-component>[代码]里面,换句话说,[代码]#shadow-root[代码]寄生在[代码]<my-component>[代码]上。[代码]#shadow-root[代码]可以嵌套,形成节点树,即称为影子树(Shadow Tree)。 像这样: [图片] Shadow Tree 拼接 既然组件是基于 Shadow DOM,那组件的嵌套关系,其实也就是 Shadow DOM 的嵌套,也可称为 Shadow Tree 的拼接。 Shadow Tree 拼接是怎么做的呢?一切又得从模版引擎讲起。 我们知道,Virtual DOM 机制会将节点解析成一个对象,那这个对象要怎么生成真正的 DOM 节点呢?数据变更又是怎么更新到界面的呢?这大概就是模版引擎做的事情了。 《前端模板引擎》里有详细描述模版引擎的机制,通常来说主要有这些: DOM 节点的创建和管理:[代码]appendChild[代码]/[代码]insertBefore[代码]/[代码]removeChild[代码]/[代码]replaceChild[代码]等 DOM 节点的关系(嵌套的处理):[代码]parentNode[代码]/[代码]childNodes[代码] 通常创建后的 DOM 节点会保存一个映射,在更新的时候取到映射,然后进行处理(通常包括替换节点、改变内容[代码]innerHTML[代码]、移动删除新增节点、修改节点属性[代码]setAttribute[代码]) 在上面的图我们也可以看到,在 Shadow Tree 拼接的过程中,有些节点并不会最终生成 DOM 节点,例如[代码]<slot>[代码]这种。 但是,常用的前端模版引擎,能直接用在小程序里吗? 双线程的难题 自定义组件渲染流程 双线程的设计,给小程序带来了很多便利,安全性管控力都拥有了,当然什么鬼东西都可以比作一把双刃剑,双线程也不例外。 我们知道,小程序分为 Webview 和 JS 双线程,逻辑层里是没法拿到真正的 DOM 节点,也没法随便动态变更页面的。那在这种情况下,我们要怎么去使用映射来更新模版呢(因为我们压根拿不到 Webview 节点的映射)? 所以在双线程下,其实两个线程都需要保存一份节点信息。这份节点信息怎么来的呢?其实就是我们需要在创建组件的时候,通过事件通知的方式,分别在逻辑层和视图层创建一份节点信息。 同时,视图层里的组件是有层级关系的,但是 JS 里没有怎么办?为了维护好父子嵌套等节点关系,所以我们在 逻辑层也需要维护一棵 Shadow Tree。 那么我们自定义组件的渲染流程大概是: 组件创建。 逻辑层:先是 wxml + js 生成一个 JS 对象(因为需要访问组件实例 this 呀),然后是 JS 其中节点部分生成 Virtual DOM,拼接 Shadow Tree 什么的,最后通过底层通信通知到 视图层 视图层:拿到节点信息,然后吭哧吭哧开始创建 Shadow DOM,拼接 Shadow Tree 什么的,最后生成真实 DOM,并保留下映射关系 组件更新。 这时候我们知道,不管是逻辑层,还是视图层,都维护了一份 Shadow Tree,要怎么保证他们之间保持一致呢? 让 JS 和 Webview 的组件保持一致 为了让两边的 Shadow Tree 保持一致,可以使用同步队列来传递信息。(这样就不会漏掉啦) 同步队列可以,每次变动我们就往队列里塞东西就好了。不过这样还会有个问题,我们也知道 setData 其实在实际项目里是使用比较频繁的,要是像 Component 的 observer 里做了 setData 这类型的操作,那不是每次变动会导致一大堆的 setDate?这样通信效率会很低吧? 所以,其实可以把一次操作里的所有 setData 都整到一次通信里,通过排序保证好顺序就好啦。 Page 和 Component Component 是 Page 的超集 事实上,小程序的页面也可以视为自定义组件。因而,页面也可以使用[代码]Component[代码]构造器构造,拥有与普通组件一样的定义段与实例方法。但此时要求对应 json 文件中包含[代码]usingComponents[代码]定义段。 来自官方文档-Component 所以,基于 Component 是 Page 的超集,那么其实组件的渲染流程、方式,其实跟页面没多大区别,应该可以一个方式去理解就差不多啦。 页面渲染 既然页面就是组件,那其实页面的渲染流程跟组件的渲染流程基本保持一致。 视图层渲染,可以参考7.4 视图层渲染说明。 结束语 其实很多新框架新工具出来的时候,经常会让人眼前一亮,觉得哇好厉害,哇好高大上。 但其实更多时候,我们需要挖掘新事物的核心,其实大多数都是在原有的事物上增加了个新视角,从不一样的视角看,看到的就不一样了呢。作为一名码农,我们要看到不变的共性,变化的趋势。
2019-04-01 - 小程序保持长连接小经验
大家都知道,小程序的websocket在切入后台5秒左右,会断开链接,或者长时间无数据收发,也会切断链接。 然而心跳数据并不能完全保证链接的正常, 我在这里说一下我的经验,我初中毕业(真的),代码方面写的不好不要见笑。 首先,我为了知道当前的网络是否断了,使用了一个标志位比如 : var NetworkIsOK = false; 当首次链接打开时比如socket的 onOpen 事件!在这个事件回调中,将NetworkIsOK = true; 同理,如果网络出现错误,在错误的回调中将NetworkIsOK = false; 建立一个发送 发送区数据 暂存站 var SendBuffer =[]; 将socket的send 函数包装一下,比如取名SendToServer(data) 在SendToServer中,首先检查网络的状态,如果状态是正常的,则直接调用socket.send()发送数据, 如果不是正常的,则重新链接服务器,并在onOpen事件中检查 SendBuffer.length是否大于 0 ,如果有存入的缓冲数据,则依次发送掉, 以下是部分实际代码,请忽略我垃圾的编程水平! [代码]var[代码] [代码]app = getApp();[代码][代码]/**[代码][代码] [代码][代码]* 与服务器进行通信的所有操作在此进行[代码][代码] [代码][代码]*/[代码][代码]var[代码] [代码]Server = {};[代码][代码]Server.socket = [代码][代码]null[代码][代码]; [代码][代码]//socket连接句柄[代码][代码]Server.isOK = [代码][代码]false[代码][代码]; [代码][代码]//服务器连接状态处理数组[代码][代码]Server.event = []; [代码][代码]//事件注册处理数组[代码][代码]Server.url = [代码][代码]'wss://********'[代码][代码]; //服务器地址[代码] [代码]Server.SendBuffer=[]; [代码][代码]//数据包发送缓冲[代码][代码]/**[代码][代码] [代码][代码]* 初始化操作[代码][代码] [代码][代码]*/[代码][代码]Server.Init=[代码][代码]function[代码][代码](apps){[代码][代码] [代码][代码]console.log([代码][代码]'hello server'[代码][代码]);[代码][代码] [代码][代码]app = apps;[代码][代码] [代码][代码]console.log(app);[代码][代码] [代码][代码]var[代码] [代码]Timer;[代码][代码] [代码][代码]/**执行服务器连接逻辑 */[代码][代码] [代码][代码]Server.socket = wx.connectSocket({[代码][代码] [代码][代码]url:Server.url[代码][代码] [代码][代码]});[代码][代码] [代码][代码]//132.232.87.229[代码][代码] [代码][代码]Server.socket.onOpen([代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]console.log([代码][代码]'Server open'[代码][代码]);[代码][代码] [代码][代码]Server.isOK=[代码][代码]true[代码][代码]; [代码][代码]//可以通信了[代码][代码] [代码][代码]//显示成功[代码][代码] [代码][代码]wx.showToast({[代码][代码] [代码][代码]title: [代码][代码]'已连接服务器 '[代码][代码],[代码][代码] [代码][代码]});[代码][代码] [代码][代码]Timer = setInterval([代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]Server.startHeart();[代码][代码] [代码][代码]}, 1000 * 50);[代码][代码] [代码][代码]});[代码][代码] [代码][代码]Server.socket.onError([代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]console.log([代码][代码]'Server error'[代码][代码]);[代码][代码] [代码][代码]Server.isOK = [代码][代码]false[代码][代码];[代码][代码] [代码][代码]//显示成功[代码][代码] [代码][代码]wx.showToast({[代码][代码] [代码][代码]title: [代码][代码]'服务器连接错误'[代码][代码],[代码][代码] [代码][代码]icon:[代码][代码]'none'[代码][代码] [代码][代码]});[代码][代码] [代码][代码]//Server.reLink();[代码][代码] [代码][代码]clearInterval(Timer);[代码][代码] [代码][代码]});[代码][代码] [代码][代码]Server.socket.onClose([代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]console.log([代码][代码]'Server close'[代码][代码]);[代码][代码] [代码][代码]Server.isOK = [代码][代码]false[代码][代码];[代码][代码] [代码][代码]//显示成功[代码][代码] [代码][代码]wx.showToast({[代码][代码] [代码][代码]title: [代码][代码]'服务器连接关闭'[代码][代码],[代码][代码] [代码][代码]icon: [代码][代码]'none'[代码][代码] [代码][代码]});[代码][代码] [代码][代码]//Server.reLink();[代码][代码] [代码][代码]clearInterval(Timer);[代码][代码] [代码][代码]});[代码][代码] [代码][代码]Server.socket.onMessage([代码][代码]function[代码] [代码](res) {[代码][代码] [代码][代码]//Server.socketPress.onMessage(res);[代码][代码] [代码][代码]var[代码] [代码]message = JSON.parse(res.data);[代码][代码] [代码][代码]if[代码][代码](!message) [代码][代码]return[代码][代码]; [代码][代码]//空数据[代码][代码] [代码][代码]var[代码] [代码]Operator = message.Operator;[代码][代码] [代码][代码]var[代码] [代码]event=Server.event[Operator];[代码][代码] [代码][代码]if[代码][代码](event){[代码][代码] [代码][代码]event(message); [代码][代码]//实际执行[代码][代码] [代码][代码]}[代码][代码] [代码][代码]});[代码][代码]} /**服务器重连**/[代码] [代码]Server.reLink=[代码][代码]function[代码][代码](){[代码][代码] [代码][代码]var[代码] [代码]Timer;[代码][代码] [代码][代码]/**执行服务器连接逻辑 */[代码][代码] [代码][代码]Server.socket = wx.connectSocket({[代码][代码] [代码][代码]url:Server.url[代码][代码] [代码][代码]});[代码][代码] [代码][代码]//132.232.87.229[代码][代码] [代码][代码]Server.socket.onOpen([代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]console.log([代码][代码]'Server open'[代码][代码]);[代码][代码] [代码][代码]Server.isOK = [代码][代码]true[代码][代码]; [代码][代码]//可以通信了[代码][代码] [代码][代码]//显示成功[代码][代码] [代码][代码]wx.showToast({[代码][代码] [代码][代码]title: [代码][代码]'已连接服务器 '[代码][代码],[代码][代码] [代码][代码]});[代码][代码] [代码][代码]Server.Login();[代码][代码]//重新注册登入[代码][代码] [代码][代码]Timer = setInterval([代码][代码]function[代码][代码](){[代码][代码] [代码][代码]Server.startHeart();[代码][代码] [代码][代码]},1000*50);[代码][代码] [代码][代码]//检查缓冲区是否有未发送数据[代码][代码] [代码][代码]while[代码] [代码](Server.SendBuffer.length>0){[代码][代码] [代码][代码]var[代码] [代码]data = Server.SendBuffer.pop();[代码][代码] [代码][代码]//将用户的code发往服务器[代码][代码] [代码][代码]Server.socket.send({[代码][代码] [代码][代码]data: data[代码][代码] [代码][代码]});[代码][代码] [代码][代码]console.log([代码][代码]'将缓存中的信息发送'[代码][代码],data)[代码][代码] [代码][代码]}[代码][代码] [代码][代码]});[代码][代码] [代码][代码]Server.socket.onError([代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]console.log([代码][代码]'Server error'[代码][代码]);[代码][代码] [代码][代码]Server.isOK = [代码][代码]false[代码][代码];[代码][代码] [代码][代码]//显示成功[代码][代码] [代码][代码]wx.showToast({[代码][代码] [代码][代码]title: [代码][代码]'服务器连接错误'[代码][代码],[代码][代码] [代码][代码]icon: [代码][代码]'none'[代码][代码] [代码][代码]});[代码][代码] [代码][代码]//Server.reLink();[代码][代码] [代码][代码]clearInterval(Timer);[代码][代码] [代码][代码]});[代码][代码] [代码][代码]Server.socket.onClose([代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]console.log([代码][代码]'Server close'[代码][代码]);[代码][代码] [代码][代码]Server.isOK = [代码][代码]false[代码][代码];[代码][代码] [代码][代码]//显示成功[代码][代码] [代码][代码]wx.showToast({[代码][代码] [代码][代码]title: [代码][代码]'服务器连接关闭'[代码][代码],[代码][代码] [代码][代码]icon: [代码][代码]'none'[代码][代码] [代码][代码]});[代码][代码] [代码][代码]//Server.reLink();[代码][代码] [代码][代码]clearInterval(Timer);[代码][代码] [代码][代码]});[代码][代码] [代码][代码]Server.socket.onMessage([代码][代码]function[代码] [代码](res) {[代码][代码] [代码][代码]//Server.socketPress.onMessage(res);[代码][代码] [代码][代码]var[代码] [代码]message = JSON.parse(res.data);[代码][代码] [代码][代码]if[代码] [代码](!message) [代码][代码]return[代码][代码]; [代码][代码]//空数据[代码][代码] [代码][代码]var[代码] [代码]Operator = message.Operator;[代码][代码] [代码][代码]var[代码] [代码]event = Server.event[Operator];[代码][代码] [代码][代码]if[代码] [代码](event) {[代码][代码] [代码][代码]event(message); [代码][代码]//实际执行[代码][代码] [代码][代码]}[代码][代码] [代码][代码]});[代码][代码]};[代码][代码]/**[代码][代码] [代码][代码]* 启动心跳[代码][代码] [代码][代码]*/[代码][代码]Server.startHeart=[代码][代码]function[代码][代码](){[代码][代码] [代码][代码]if[代码] [代码](Server.isOK == [代码][代码]false[代码][代码]) {[代码][代码] [代码][代码]Server.reLink(); [代码][代码]//重连[代码][代码] [代码][代码]return[代码][代码];[代码][代码] [代码][代码]}[代码][代码] [代码][代码]var[代码] [代码]sendData = {[代码][代码] [代码][代码]Operator: [代码][代码]'Heart'[代码][代码], [代码][代码]//操作方式[代码][代码] [代码][代码]};[代码][代码] [代码][代码]var[代码] [代码]JsonData = JSON.stringify(sendData);[代码][代码] [代码][代码]// console.log(JsonData);[代码][代码] [代码][代码]//将用户的code发往服务器[代码][代码] [代码][代码]Server.socket.send({[代码][代码] [代码][代码]data: JsonData[代码][代码] [代码][代码]});[代码][代码] [代码][代码]Server.addEvent([代码][代码]'HeartOK'[代码][代码],[代码][代码]function[代码][代码](message){[代码][代码] [代码][代码]// console.log('心跳OK',message);[代码][代码] [代码][代码]})[代码][代码]}[代码][代码]/**用户登陆 */[代码][代码]Server.Login=[代码][代码]function[代码][代码](ques){[代码][代码] [代码][代码]var[代码] [代码]userOpenId = wx.getStorageSync([代码][代码]'openId'[代码][代码]);[代码][代码] [代码][代码]console.log(app);[代码][代码] [代码][代码]var[代码] [代码]sendData = {[代码][代码] [代码][代码]Operator: [代码][代码]'userLogin'[代码][代码], [代码][代码]//操作方式[代码][代码] [代码][代码]userCode: app.userCode, [代码][代码]//用户code[代码][代码] [代码][代码]userInfo: app.userInfo, [代码][代码]//用户信息[代码][代码] [代码][代码]};[代码][代码] [代码][代码]if[代码] [代码](userOpenId) { [代码][代码]//如果缓存中的有效,就用缓存中的openId发过去给服务器[代码][代码] [代码][代码]sendData.userOpenId = userOpenId;[代码][代码] [代码][代码]console.log([代码][代码]'调用缓存openID'[代码][代码]);[代码][代码] [代码][代码]}[代码] [代码] [代码][代码]var[代码] [代码]JsonData = JSON.stringify(sendData);[代码][代码] [代码][代码]console.log([代码][代码]"登陆数据:"[代码][代码], JsonData);[代码][代码] [代码][代码]// console.log(JsonData);[代码][代码] [代码][代码]//将用户的code发往服务器[代码][代码] [代码][代码]Server.SendData(sendData);[代码][代码] [代码][代码]/**如果是黑名单用户,禁止使用 */[代码][代码] [代码][代码]Server.addEvent([代码][代码]'BanLogin'[代码][代码],[代码][代码]function[代码][代码](message){[代码][代码] [代码][代码]if[代码] [代码](ques) {[代码][代码] [代码][代码]ques();[代码][代码] [代码][代码]}[代码][代码] [代码][代码]});[代码][代码]};[代码][代码]/**[代码][代码] [代码][代码]* 注册消息执行处理函数[代码][代码] [代码][代码]*/[代码][代码]Server.addEvent=[代码][代码]function[代码][代码](eventName,eventHandle){[代码][代码] [代码][代码]Server.event[eventName] = eventHandle;[代码][代码]}[代码][代码]/**注册登入成功处理事件 */[代码][代码]Server.addEvent([代码][代码]'LoginOK'[代码][代码],[代码][代码]function[代码][代码](message){[代码][代码] [代码][代码]console.log([代码][代码]'LOGIN OK'[代码][代码]);[代码][代码] [代码][代码]console.log(message);[代码][代码] [代码][代码]wx.setStorageSync([代码][代码]'openId'[代码][代码],message.openId);[代码][代码]});[代码][代码]/**注册登入失败处理事件 */[代码][代码]Server.addEvent([代码][代码]'LoginError'[代码][代码], [代码][代码]function[代码] [代码](message) {[代码][代码] [代码][代码]wx.showModal({[代码][代码] [代码][代码]content: [代码][代码]'登入失败了!部分功能可能无法使用,可能是网络原因,也可能是没有获得授权'[代码][代码],[代码][代码] [代码][代码]showCancel: [代码][代码]false[代码][代码],[代码][代码] [代码][代码]success: [代码][代码]function[代码] [代码](res) {[代码][代码] [代码][代码]if[代码] [代码](res.confirm) {[代码][代码] [代码][代码]//console.log('用户点确定');[代码][代码] [代码][代码]}[代码][代码] [代码][代码]}[代码][代码] [代码][代码]});[代码][代码]});[代码][代码]/**[代码][代码] [代码][代码]* 创建帖子[代码][代码] [代码][代码]*/[代码][代码]Server.CreateInvitation=[代码][代码]function[代码][代码](table,image,text,isRichText){[代码][代码] [代码][代码]var[代码] [代码]sendData = {[代码][代码] [代码][代码]Operator: [代码][代码]'CreateInvitation'[代码][代码], [代码][代码]//操作方式[代码][代码] [代码][代码]isRichText:isRichText,[代码][代码]//是否为富文本[代码][代码] [代码][代码]Table:table,[代码][代码]//标题[代码][代码] [代码][代码]Image:image,[代码][代码]//图片地址[代码][代码] [代码][代码]Text:text,[代码][代码]//文本内容[代码][代码] [代码][代码]};[代码][代码] [代码][代码]Server.SendData(sendData);[代码][代码]};[代码][代码]/**[代码][代码] [代码][代码]* 从服务器获取贴子[代码][代码] [代码][代码]*/[代码][代码]Server.GetInvitation=[代码][代码]function[代码][代码](id,mode,time,limt,skip,success){[代码][代码] [代码][代码]var[代码] [代码]sendData = {[代码][代码] [代码][代码]Operator: [代码][代码]'GetInvitation'[代码][代码], [代码][代码]//操作方式[代码][代码] [代码][代码]id:id, [代码][代码]//使用帖子ID的查询方式[代码][代码] [代码][代码]mode:mode, [代码][代码]//操作模式[代码][代码] [代码][代码]time:time, [代码][代码]//时间查询时使用的时间[代码][代码] [代码][代码]limt:limt, [代码][代码]//分页查询时需要获取的贴子数量[代码][代码] [代码][代码]skip:skip, [代码][代码]//需要跳过的帖子数量[代码][代码] [代码][代码]};[代码][代码] [代码][代码]//var JsonData = JSON.stringify(sendData);[代码][代码] [代码][代码]Server.SendData(sendData);[代码][代码] [代码][代码]/**注册登入成功处理事件 */[代码][代码] [代码][代码]Server.addEvent([代码][代码]'GetInvitationOK'[代码][代码], [代码][代码]function[代码] [代码](message) {[代码][代码] [代码][代码]console.log([代码][代码]'GetInvitationOK'[代码][代码]);[代码][代码] [代码][代码]var[代码] [代码]data = message.data; [代码][代码] [代码][代码]var[代码] [代码]serverTime=message.serverTime;[代码][代码] [代码][代码]if[代码][代码](success) success(data,serverTime); [代码][代码]//回调执行[代码][代码] [代码][代码]});[代码][代码]}[代码][代码]/**[代码][代码] [代码][代码]* 创建帖子评论[代码][代码] [代码][代码]*/[代码][代码]Server.CreateInvitationComment=[代码][代码]function[代码][代码](_id,text,success){[代码][代码] [代码][代码]var[代码] [代码]sendData = {[代码][代码] [代码][代码]Operator: [代码][代码]'CreateInvitationComment'[代码][代码], [代码][代码]//操作方式[代码][代码] [代码][代码]id:_id, [代码][代码]//使用帖子ID的查询方式[代码][代码] [代码][代码]text:text,[代码][代码]//评论内容[代码][代码] [代码][代码]};[代码][代码] [代码][代码]Server.SendData(sendData);[代码][代码] [代码][代码]/**注册登入成功处理事件 */[代码][代码] [代码][代码]Server.addEvent([代码][代码]'CreateInvitationCommentOK'[代码][代码], [代码][代码]function[代码] [代码](message) {[代码][代码] [代码][代码]console.log([代码][代码]'CreateInvitationCommentOK'[代码][代码]);[代码][代码] [代码][代码]var[代码] [代码]data = message.data;[代码][代码] [代码][代码]if[代码] [代码](success) success(data); [代码][代码]//回调执行[代码][代码] [代码][代码]});[代码][代码]};[代码][代码]/**[代码][代码] [代码][代码]* 给帖子点赞[代码][代码] [代码][代码]*/[代码][代码]Server.LoveInvitation=[代码][代码]function[代码][代码](_id,success){[代码][代码] [代码][代码]var[代码] [代码]sendData = {[代码][代码] [代码][代码]Operator: [代码][代码]'LoveInvitation'[代码][代码], [代码][代码]//操作方式[代码][代码] [代码][代码]id:_id, [代码][代码]//使用帖子ID的查询方式[代码][代码] [代码][代码]};[代码][代码] [代码][代码]Server.SendData(sendData);[代码][代码] [代码][代码]/**注册登入成功处理事件 */[代码][代码] [代码][代码]Server.addEvent([代码][代码]'LoveInvitationOK'[代码][代码], [代码][代码]function[代码] [代码](message) {[代码][代码] [代码][代码]console.log([代码][代码]'LoveInvitationOK'[代码][代码]);[代码][代码] [代码][代码]if[代码] [代码](success) success(); [代码][代码]//回调执行[代码][代码] [代码][代码]});[代码][代码]}[代码][代码]/**[代码][代码] [代码][代码]* 向服务器发送数据,数据类型为任意数据,[代码][代码] [代码][代码]* 如果服务器断线,则自动重连服务器,数据被暂存,重连成功后将被发送[代码][代码] [代码][代码]*/[代码][代码]Server.SendData=[代码][代码]function[代码][代码](data){[代码][代码] [代码][代码]if[代码] [代码](Server.isOK == [代码][代码]false[代码][代码]) {[代码][代码] [代码][代码]Server.reLink(); [代码][代码]//重连[代码][代码] [代码][代码]//将未发送数据存入Buffer[代码][代码] [代码][代码]var[代码] [代码]JsonData = JSON.stringify(data);[代码][代码] [代码][代码]Server.SendBuffer.push(JsonData);[代码][代码] [代码][代码]return[代码][代码];[代码][代码] [代码][代码]}[代码][代码]else[代码][代码]{ [代码][代码]//否则直接给服务器发送数据[代码][代码] [代码][代码]var[代码] [代码]JsonData = JSON.stringify(data);[代码][代码] [代码][代码]Server.socket.send({[代码][代码] [代码][代码]data: JsonData[代码][代码] [代码][代码]});[代码][代码] [代码][代码]}[代码][代码]}[代码][代码]//暴露接口[代码][代码]module.exports.Server = Server;[代码]
2019-03-18 - 爬虫vs反爬虫
爬虫介绍 爬虫简单介绍就是一个获取数据的途径。有时我们需要进行数据分析等操作,都会将别人网站中现成的数据放入我们自己本地数据库内,这时候,我们可以使用爬虫来实现。 网站的重要资料、信息财产被轻易窃取,是不能随便泄漏的。我们就应该使用反爬虫技术。本文将依次先将常见的反爬虫技术,与对应的爬虫技巧。 爬虫原理 一般我们访问网络资源都是通过uri。我们要获取的信息,一般有两种常见形式。json或html。html一般是后端服务器渲染后返回的,json是服务器直接返回给前端,然后前端自己在将数据渲染到页面上。如果是json类型的话,可以直接请求这个uri,或者等待前端渲染完毕再从html获取。获取到html信息后,通过dom操作,即可获得对应内容。爬取手机app的内容时,需要使用抓包工具:Fiddler,Charles,Wireshark。 最简单的爬虫,用shell就可以实现。复杂的爬虫,甚至需要用到机器学习分析。 注意:大部分情况爬虫是没有法律问题的,只有网站明确声明了禁止使用网络爬虫和转载商业化时(付费知识)时,爬虫才会触犯法律 为了更快的创建一只爬虫,请先安装Postman,Chrome等软件,用于发送请求,并可以查看详细请求和响应头部和内容 如果有反爬虫需要,请一定要记录服务器请求日志。分析日志才能找出潜在爬虫,即使没有及时对应的解决的方案,也可以暂时将其拉入黑名单。 爬虫流程 [图片] 整体流程的关键在于将网上的数据通过机器方式自动获得。没有反爬虫机制的 uri,还可以使用分布式爬虫,开启多线程,快速爬取信息。遇到了反爬虫机制,一般来说用上对应的反反爬虫方法就行了。大部分网站仅仅是对游客有访问限制,如果不想注册账号,基本都可以使用代理方式解决。也可以在登录之后复制登录的完整请求头解决。但是对于某些数据会有验证码拦截。这时我们就要将网站使用的验证码分类并在网上找到对应的解决方案。 查看请求 使用Chrom浏览器(也可以用别的,这里只用Chrome做例子)访问我们要爬取的页面。通过F12进入开发者工具,切换到network面板下(没有数据,请刷新再次请求) 找到要获取数据的URI [图片] 请求所携带的请求头都在这里,完整附带请求信息,可以充分模拟浏览器。 [图片] 直接通过Chrome将请求信息导出,在自己的终端下尝试请求。 由于Google页面携带的cookie内容较多,而且hk站点下编码不是常见的UTF8格式,会出现乱码,这里就以百度为例。 [代码]curl 'https://www.baidu.com/' -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:67.0) Gecko/20100101 Firefox/67.0' -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' -H 'Accept-Language: en-US,en;q=0.5' --compressed -H 'DNT: 1' -H 'Connection: keep-alive' -H 'Cookie: BAIDUID=606890F9A814F1194EEA6EC7D743CC84:FG=1; BIDUPSID=606890F9A814F1194EEA6EC7D743CC84; PSTM=1553581103; delPer=0; BD_HOME=0; H_PS_PSSID=26524_1461_21106_28722_28557_28697_28584_26350_28604_28606; BD_UPN=133252; BDORZ=B490B5EBF6F3CD402E515D22BCDA1598' -H 'Upgrade-Insecure-Requests: 1' -H 'Pragma: no-cache' -H 'Cache-Control: no-cache' [代码] 添加-o 将本次请求的response保存为文件。某些页面可能会返回乱码,是因为开启了gzip或者编码需要重新设置,部分网页编码非UTF8。 直接访问 这种网站一般数据都是完全公开的,可以算是0反爬虫。对于这种网站,直接请求即可。这种爬虫最好做,所以一般搜索引擎也最好搜索。很多网站即使内部有许多反爬虫机制,但是首页还是会为了SEO,一般不会反爬虫。 鉴别浏览器 通过浏览器进行网站访问时,都会携带user-agent信息。而在本地请求时,并不会携带这些信息。同时浏览器会保持一个session会话,而一般的request模块中,并不能携带session。我们可以通过session存放标记或者判断header部分,来鉴别爬虫。 爬虫方案 对于session的判断,使用带session的模块。比如python的requests或者使用headless。 对于header的判断,可以在浏览器的请求内复制完整的请求头。或者直接使用headless 判断token token一般不用于自己的主站。访问过很多网站,我只见过掘金在使用。token的目的不是反爬虫,而是防止用户的账号密码泄露。 先简单讲讲token步骤 客户端第一次登录 服务器确认登录并返回有期限的合法token 客服端进行请求时携带token 服务器判断token识别合法用户,然后在执行请求 爬虫方案 一般服务器都是使用sesssion的,但如果确实使用了token的。我们可以分析接口,获取token,并在请求时携带token即可。 限制ip访问频率 一般而言限制ip访问,不只是为了减少爬虫的数量,更是为了防止DDOS之类攻击。因为同一ip的大量访问,将会占用服务器的大量资源。如果触发的还是一个费时的操作,将会导致服务器来不及处理其他正常用户的请求。可以直接通过nginx自动让网站都有一个拦截ip频繁访问。 其实大部分网站还是会设置一定的阈值的,可能是1小时的访问次数,又或者每日,每个游客用户的访问次数。 爬虫方案 通过添加定时器:随机几秒延迟,不超过服务器的阈值,简单模拟人的访问频率。 使用代理服务器:大部分语言的请求,都提供了proxy的api。使用代理后,就可避开ip的限制了。所以我们反爬虫时尽力用非ip方式判断是不是用了proxy在爬取服务器 使用网站登录信息:部分网站是对游客有所限制,对于登录的用户会有更多访问次数。所以可以利用这点提高频率。 验证码 验证码一直是一个反人类的玩意。但是他设计的目的是为了反机器。验证码和破解验证码有着很长的战争历史。大部分后端语言都有快速部署数字验证码的组件。 爬虫方案 最简单的方破解法就是使用登录信息或者用代理更换ip,不过部分情况还是会遇到不可避免的验证码的。 使用第三方平台接入,手工在线解验证码。 使用机器学习配合headless,破解验证码 滑动验证码 [图片] 现在经常出现的滑动验证码,使用的是极验提供的验证码。通过滑动确实让用户的操作成本降低了。 爬虫方案 网上出现了不少的破解案例。这里推荐使用headless将通过代码能让鼠标执行操作验证码。 图片文字验证码 [图片] 爬虫方案 这种验证码比上面的要求更高,提高的用户的成本。使用的比较少,但是破解方法还是有的,就是使用OCR或者机器学习。 前端动态渲染 现代前端日益强大,许多事情在前端都可以完成。为了减轻服务器的压力。有些网站已经从传统的JSP,PHP渲染转为,Java,PHP提供接口,由前端自行渲染。 爬虫方案 这时候,我们可以再次在Network面板中查看获取信息的api接口。但是部分时候,数据会比较复杂,晦涩。甚至还有加密信息在返回的数据之内。其实我们也可能让数据直接在网页上自己渲染出来,就是通过headless等无头浏览器。实现 图片代替文字,字体映射 这种反爬方案比较高级,单一般用户体验也不会很高,成本也不低。遇到还是换个网站比较好。 headless无头浏览器 Node.js Python Puppeteer、phantomjs、Splash Selenium 更多headless 使用headless基本上算是终极解决方案了,用代码的方式去执行一个no GUI的浏览器。包括鼠标的移动,点击,拖动等。而且还能自动携带Session和Cookie信息,不过headless设计的初衷其实是前端自动化测试… [代码]const puppeteer = require('puppeteer'); async function getPage() { //创建实例 const browser = await puppeteer.launch(); //新建页面 const page = await browser.newPage(); await page.goto("https://juejin.im"); //等待1秒 await page.waitFor(1000); //截屏 await page.screenshot({path: `preview.png`}); //关闭实例 await browser.close(); } getPage(); [代码] 这段代码可以将SPA页面加载出来,查看效果可以使用api提供的screenshot截屏功能 [图片] 注意:使用headless需要加入渲染页面的性能,会导致爬虫性能极速下降(毕竟本来设计目的不是爬虫) 总结 其实为什么反爬虫没过多久都能被破解呢?其实主要原因是浏览器的用户信息的透明的,我们可以通过浏览器就可以看到开发的前端源代码。即使使用了各种技术,只要有前端,耐心的分析还是可以破解的。就算不行,我也可以通过headless确实以浏览器方式进行访问。即使是App也可以通过抓包,分析api,进行爬虫。 其实做反爬虫就好像是图灵测试,通过一系列的方法来辨别当前访问者是人还是机器。但是这个测试又不能使用户感到返感。
2019-03-26 - Web直播,你需要先知道这些
转自IMWeb社区,原文链接 Web直播,你需要先知道这些 直播知识小科普 一个典型的直播流程:录制->编码->网络传输(推流->服务器处理->CDN分发)->解码->播放 IPB:一种常用的视频压缩方案,用I帧表示关键帧,B帧表示前向差别帧,P帧表示双向差别帧 GOP (Group of Pictures):GOP 越长(I帧之间的间隔越大),B 帧所占比例越高,编码的率失真性能越高。虽然B帧压缩率高,但解码时CPU压力会更大。 音视频直播质量好坏的主要指标:内容延时、卡顿(流畅度)、首帧时长 音视频直播需要克服的主要问题:网络环境、多人连麦、主辅路、浏览器兼容性、CDN支持等 MSE(Media Source Extensions):W3C 标准API,解决 HTML5 的流问题(HTML5 原生仅支持播放 mp4/webm 非流格式,不支持 FLV),允许JavaScript动态构建 [代码]<video>[代码] 和 [代码]<audio>[代码] 的媒体流。可以用MediaSource.isTypeSupported() 判断是否支持某种MINE类型。在ios Safari中不支持。 [图片] 文件格式/封装格式/容器格式:一种承载视频的格式,比如flv、avi、mpg、vob、mov、mp4等。而视频是用什么方式进行编解码的,则与Codec相关。举个栗子,MP4格式根据编解码的不同,又分为nMP4、fMP4。nMP4是由嵌套的Boxes 组成,fMP4格式则是由一系列的片段组成,因此只有后者不需要加载整个文件进行播放。 Codec:多媒体数字信号编码解码器,能够对音视频进行压缩(CO)与解压缩( DEC ) 。CODEC技术能有效减少数字存储占用的空间,在计算机系统中,使用硬件完成CODEC可以节省CPU的资源,提高系统的运行效率。 常用视频编码:MPEG、H264、RealVideo、WMV、QuickTime。。。 常用音频编码:PCM、WAV、OGG、APE、AAC、MP3、Vorbis、Opus。。。 现有方案比较 RTMP协议 基于TCP adobe垄断,国内支持度高 浏览器端依赖Flash进行播放 2~5秒的延迟 RTP协议 Real-time Transport Protocol,IETF于1996提出的一个标准 基于UDP 实时性强 用于视频监控、视频会议、IP电话 CDN厂商、浏览器不支持 HLS 协议 Http Live Streaming,苹果提出的基于HTTP的流媒体传输协议 HTML5直接支持(video),适合APP直播,PC断只有Safari、Edge支持 必须是H264+AAC编码 因为传输的是切割后的音视频片段,导致内容延时较大 [图片] flv.js Bilibli开源,解析flv数据,通过MSE封装成fMP4喂给video标签 编码为H264+AAC 使用HTTP的流式IO(fetch或stream)或WebSocket协议流式的传输媒体内容 2~5秒的延迟,首帧比RTMP更快 WebRTC协议 [图片] 1、Google力推,已成为W3C标准 2、现代浏览器支持趋势,X5也支持(微信、QQ) [图片] 3、基于UDP,低延迟,弱网抗性强,比flv.js更有优势 方案 CPU占用 帧率 码率 延时 首帧 flv.js 0.4 30 700kbit/s 1.5s 2s WebRTC 1.9 30 700kbit/s 0.7s 1.5s 4、支持Web上行能力 5、编码为H264+OPUS 6、提供NAT穿透技术(ICE) **实际情况下,当用户数量很大时,对推流设备的性能要求很高,复杂的权限管理也难以实现,采用P2P的架构基本不可行。对于个别用户提供上行流、海量用户只进行拉流的场景,腾讯课堂实现了一种P2S的解决方案。**进一步学习可阅读jaychen的系列文章《WebRTC直播技术》。 [图片] 小程序+直播 技术方案 基于RTMP,官方说底层使用HTTP/2的一种内部传输机制,但又说是基于UDP的,这就搞不懂了。。。 live-pusher 和 live-player 没有限制第三方云服务 可直接使用腾讯云视频直播能力,只需配置好推流url、播放url即可 推流url: [图片] 播放url: [图片] 下面是我根据官网教程搭建的一个音视频小程序,搭建过程简单,同一个局域网下直播体验也很流畅(读者也可直接搜索腾讯视频云小程序进行体验): [图片] 前端核心代码还是相当简洁的: live-pusher组件:设置好url推流地址(仅支持 flv, rtmp 格式)等参数即可,使用bindstatechange获取播放状态变化 [图片] live-player组件:设置后src音视频地址(仅支持 flv, rtmp 格式)等参数即可,使用bindstatechange获取播放状态变化 [图片] 能否和WebRTC同时使用? 对于腾讯课堂的应用场景,老师上课推流采用的是RTMP协议,考虑到WebRTC目前只能用于PC端拉流,那么在移动端能否让用户可以直接通过小程序来观看直播课呢?我觉得在技术层面可行的,接入小程序直播对于扩大平台影响力、社交圈分享、提高收费转化都会产生很大的帮助。难点在于复杂的权限控制、多路音视频流、多人连麦等问题,比如权限控制只能单独放到房间控制逻辑中完成,而音视频流本身缺乏这种校验;主辅路的切换还需要添加单独的信令控制,同时在小程序中加入相应的判断逻辑。 补充:最近看到已经有小程序的webrtc方案了,基于live-player、live-pusher组件,加入腾讯云强大的音视频后台服务,官方提供了一套封装度更高的自定义组件方案 —— <webrtc-room> ,甚至可以和Chrome打通。详情可以参考WebRTC 互通、webrtc-room [图片] 参考文章 HTTP 协议入门 使用flv.js做直播 面向未来的直播技术-WebRTC【视频、PPT】 小程序音视频能力技术负责人解读“小程序直播” 小程序开发简易教程 小程序音视频解读
2019-03-26 - 云开发实战分享|一天搭建一个社区
原创:龙衣 前言 虽然不会后台开发,但是也想自己做项目,正好云开发出现了。开发者可以使用云开发开发微信小程序、小游戏,无需搭建服务器,即可使用云端能力。 社区作为一个交流的平台,可以通过发布自己、别人喜欢的文字、图片的方式进行交流分享。 刚学完云开发,正好可以用社区小程序项目练练手~ 【社区小程序】功能实现 首页【广场】 ●显示用户发布的内容 ●管理员发布的一些教程 消息【发布】 ●发布图文 ●水平图片的滑动显示 个人中心【我的】 ●显示用户的登录信息 ●用户的收藏列表 ●发布历史 ●邀请好友 ●产品意见 [图片] 一、首页【广场】 显示用户发布的内容 管理员发布的一些教程 实现的效果 [图片] 实现要点 1.WXML 不同类别数据的显示 通过 if-elif-else 实现,在wxml文件中通过 <block></block>渲染,因为它仅仅是一个包装元素,不会在页面中做任何渲染,只接受控制属性。也就是说可以通过属性来控制页面是否要渲染这部分的内容,可以减少页面渲染时间。 2.云开发数据的获取 先开通云开发功能 ,参考官方文档,然后在创建项目的时候勾选上 使用云开发模板(看个人吧,我直接使用后点击项目中的 login)就可以获取到用户的 [代码]oppenid[代码],之后就可以使用云数据库了。 [图片] 云开发登录: [图片] 云数据的获取: [代码]/** * 生命周期函数--监听页面加载 */ onLoad: function(options) { console.log('onload'); this.getData(this.data.page); }, /** * 获取列表数据 * */ getData: function(page) { var that = this; console.log("page--->" + page); const db = wx.cloud.database(); // 获取总数 db.collection('topic').count({ success: function(res) { that.data.totalCount = res.total; } }) // 获取前十条 try { db.collection('topic') .where({ _openid: 'oSly***********vU1KwZE', // 填入当前用户 openid }) .limit(that.data.pageSize) // 限制返回数量为 10 条 .orderBy('date', 'desc') .get({ success: function(res) { // res.data 是包含以上定义的两条记录的数组 // console.log(res.data) that.data.topics = res.data; that.setData({ topics: that.data.topics, }) wx.hideNavigationBarLoading();//隐藏加载 wx.stopPullDownRefresh(); }, fail: function(event) { wx.hideNavigationBarLoading();//隐藏加载 wx.stopPullDownRefresh(); } }) } catch (e) { wx.hideNavigationBarLoading();//隐藏加载 wx.stopPullDownRefresh(); console.error(e); } }, [代码] 云数据的添加: [代码]/** * 保存到发布集合中 */ saveDataToServer: function(event) { var that = this; const db = wx.cloud.database(); const topic = db.collection('topic') db.collection('topic').add({ // data 字段表示需新增的 JSON 数据 data: { content: that.data.content, date: new Date(), images: that.data.images, user: that.data.user, isLike: that.data.isLike, }, success: function(res) { // res 是一个对象,其中有 _id 字段标记刚创建的记录的 id // 清空,然后重定向到首页 console.log("success---->" + res) // 保存到发布历史 that.saveToHistoryServer(); // 清空数据 that.data.content = ""; that.data.images = []; that.setData({ textContent: '', images: [], }) that.showTipAndSwitchTab(); }, complete: function(res) { console.log("complete---->" + res) } }) }, [代码] 3.数据列表的分页 主要就是定义一个临时数组存放加载上来的数据,然后通过传递给对象,最后传递到布局中去。 [代码]/** * 页面上拉触底事件的处理函数 */ onReachBottom: function() { var that = this; var temp = []; // 获取后面十条 if(this.data.topics.length < this.data.totalCount){ try { const db = wx.cloud.database(); db.collection('topic') .skip(5) .limit(that.data.pageSize) // 限制返回数量为 5 条 .orderBy('date', 'desc') // 排序 .get({ success: function (res) { // res.data 是包含以上定义的两条记录的数组 if (res.data.length > 0) { for(var i=0; i < res.data.length; i++){ var tempTopic = res.data[i]; console.log(tempTopic); temp.push(tempTopic); } var totalTopic = {}; totalTopic = that.data.topics.concat(temp); console.log(totalTopic); that.setData({ topics: totalTopic, }) } else { wx.showToast({ title: '没有更多数据了', }) } }, fail: function (event) { console.log("======" + event); } }) } catch (e) { console.error(e); } }else{ wx.showToast({ title: '没有更多数据了', }) } }, [代码] 二、消息【发布】 发布图文 水平图片的滑动显示(效果不是很好,可以改为九宫格实现) 发布页面效果如下: [图片] 分析如何实现 导航栏的实现很简单就不说了,可参考我之前的文章 重点是中间的 ② 是内容区域 区域三是功能操作区 内容区域的实现 第一个是文本区域 第二个是水平的图片展示区域 在图片的右上角有关闭按钮,这里使用的是icon组件。 主要的实现代码如下: [代码]<view class="content"> <form bindsubmit="formSubmit"> <view class="text-content"> <view class='text-area'> <textarea name="input-content" type="text" placeholder="说点什么吧~" placeholder-class="holder" value="{{textContent}}" bindblur='getTextAreaContent'></textarea> </view> </view> <scroll-view class="image-group" scroll-x="true"> <block wx:for='{{images}}' wx:for-index='idx'> <view> <image src='{{images[idx]}}' mode='aspectFill' bindtap="previewImg"></image> <icon type='clear' bindtap='removeImg' data-index="{{idx}}" ></icon> </view> </block> </scroll-view> <view class='btn-func'> <button class="btn-img" bindtap='chooseImage'>选择图片</button> <button class="btn" formType='submit' open-type="getUserInfo">发布圈圈</button> <!-- <image hidden=''></image> --> </view> </form> </view> [代码] 布局样式如下: [代码].content { height: 100%; width: 100%; } textarea { width: 700rpx; padding: 25rpx 0; } .text-content { background-color: #f3efef; padding: 0 25rpx; } .image-group { display: flex; white-space: nowrap; margin-top: 30px; } .image-group view{ display: inline-block; flex-direction: row; width: 375rpx; height: 375rpx; margin-right: 20rpx; margin-left: 20rpx; background-color: #cfcccc; } .image-group view image{ width: 100%; height: 100%; align-items: center; } .image-group view icon{ display: inline-block; vertical-align: top; position: absolute } .btn-func { display: flex; flex-direction: column; width: 100%; position: absolute; bottom: 0; margin: 0 auto; align-items: center; } .btn-img { width: 220px; height: 45px; line-height: 45px; margin-top: 20px; margin-bottom: 20px; background-color: rgb(113, 98, 250); color: #fff; border-radius: 50px; } .btn { width: 220px; height: 45px; line-height: 45px; background-color: #d50310; color: #fff; border-radius: 50px; margin-bottom: 20px; } [代码] 页面布局之后就该从 js中去处理数据了,在js中主要实现的功能有: 文本内容的获取 图片的选择 图片的阅览 图片的删除 将结果发布到云数据库中 1.文本内容的获取 [代码] /** * 获取填写的内容 */ getTextAreaContent: function(event) { this.data.content = event.detail.value; }, [代码] 2.图片的选择 [代码] /** * 选择图片 */ chooseImage: function(event) { var that = this; wx.chooseImage({ count: 6, success: function(res) { // tempFilePath可以作为img标签的src属性显示图片 const tempFilePaths = res.tempFilePaths for (var i in tempFilePaths) { that.data.images = that.data.images.concat(tempFilePaths[i]) } // 设置图片 that.setData({ images: that.data.images, }) }, }) }, [代码] 3.图片的预览 [代码] // 预览图片 previewImg: function(e) { //获取当前图片的下标 var index = e.currentTarget.dataset.index; wx.previewImage({ //当前显示图片 current: this.data.images[index], //所有图片 urls: this.data.images }) }, [代码] 4.图片的删除 [代码]/** * 删除图片 */ removeImg: function(event) { var position = event.currentTarget.dataset.index; this.data.images.splice(position, 1); // 渲染图片 this.setData({ images: this.data.images, }) }, [代码] 5.发布内容到数据库中 数据发布到数据中,需要先开启云开发,然后在数据库中创建集合也就是表之后就是调用数据库的增删改查API即可。 [代码] /** * 添加到发布集合中 */ saveToHistoryServer: function(event) { var that = this; const db = wx.cloud.database(); db.collection('history').add({ // data 字段表示需新增的 JSON 数据 data: { content: that.data.content, date: new Date(), images: that.data.images, user: that.data.user, isLike: that.data.isLike, }, success: function(res) { // res 是一个对象,其中有 _id 字段标记刚创建的记录的 id console.log(res) }, fail: console.error }) }, [代码] 三、个人中心【我的】 【显示用户的登录信息】主要就是调用小程序接口,获取用户的微信公开信息进行展示 【用户的收藏列表】获取数据库中的收藏列表进行展示 【发布历史】在发布页面,当发布成功将数据存到发布历史表中,需要的时候获取该表的数据进行展示 【邀请好友】调用小程序的分享接口,直接分享给微信群,或者个人 【产品意见】一个类似于发布页的页面,实现思路和发布页实现是一样的。 实现的效果 [图片] 实现分析 1.要实现的效果 在用户进入个人中心,直接弹出获取用户信息弹窗 显示圆形的用户头像 2.授权弹窗 官方获取用户信息文档调整 为优化用户体验,使用 wx.getUserInfo 接口直接弹出授权框的开发方式将逐步不再支持。从2018年4月30日开始,小程序与小游戏的体验版、开发版调用 wx.getUserInfo 接口,将无法弹出授权询问框,默认调用失败。正式版暂不受影响。 也就是以前的 wx.getUserInfo不直接弹出授权窗口了,而且在新版中调用会直接返回fail,现在的做法呢就是通过点击一个button 去实现用户授权功能。 文档中说明了有两种方式能够获取用户信息。 一个是利用 [代码]<open-data>[代码]获取公开的用户信息: [代码]<open-data type="userNickName" lang="zh_CN"></open-data> <open-data type="userAvatarUrl"></open-data> <open-data type="userGender" lang="zh_CN"></open-data> [代码] 另一个是利用button组件将 open-type 指定为 getUserInfo类型: [代码] <!-- 需要使用 button 来授权登录 --> <button wx:if="{{canIUse}}" open-type="getUserInfo" bindgetuserinfo="bindGetUserInfo">授权登录</button> <view wx:else>请升级微信版本</view> Page({ data: { canIUse: wx.canIUse('button.open-type.getUserInfo') }, onLoad: function() { // 查看是否授权 wx.getSetting({ success (res){ if (res.authSetting['scope.userInfo']) { // 已经授权,可以直接调用 getUserInfo 获取头像昵称 wx.getUserInfo({ success: function(res) { console.log(res.userInfo) } }) } } }) }, bindGetUserInfo (e) { // 获取到用户信息 console.log(e.detail.userInfo) } }) [代码] 3.<open-data>中实现圆形头像 [代码]<view class='amountBg'> <view class='img'> <open-data type="userAvatarUrl"></open-data> </view> <view class='account'> <view class='nick-name'> <open-data type="userNickName" lang="zh_CN"></open-data> </view> <view class='address'> <open-data type="userCountry" lang="zh_CN"></open-data>· <open-data type="userProvince" lang="zh_CN"></open-data>· <open-data type="userCity" lang="zh_CN"></open-data> </view> </view> </view> [代码] css 样式如下: [代码].amountBg { display: flex; flex-direction: row; height: 100px; background-color: #5495e6; align-items: center; } .img { overflow: hidden; display: block; margin-left: 20px; width: 49px; height: 49px; border-radius: 50%; } .account { width: 70%; color: #fff; margin-left: 10px; align-items: center; } .nick-name{ font-family: 'Mcrosoft Yahei'; font-size: 16px; } .address{ font-size: 13px; } .nav { width: 15px; color: #fff; } [代码] 可能存在的一些问题 其他用户发布的内容,有时候显示不出来? 将数据库的权限设置为全部人可见。 发布内容之后返回首页没有自动刷新? 在广场首页 onShow 的时候获取数据库的数据进行展示。 clone 源码后运行不起来? 需要在自己的云数据库中创建对应的表。
2019-03-29 - docker快速入门
1. 介绍docker是什么Docker使用go基于linux lxc(linux containers)技术实现的开源容器,诞生于2013年年初,最开始叫dotcloud公司,13年年底改名为docker inc。 2017年下载次数达到了百亿次,估值达13亿美元,通过对应用封装(Packaging)、分发(Distribution)、部署(Deployment)、运行(Runtime)全生命周期管理,达到“一次封装,到处运行” [图片] 为何使用docker?Docker直译码头工人,将各种大小和形状的物品装进船里。这对从事软件行业的人来说,听起来很熟悉,花了大量时间和精力把一个应用放在另一个应用里 [图片] docker出现之前,对不同环境的安装、配置、维护工作量很多,如部署,配置文件,crontab,依赖等等。 使用docker,无需关心环境,只需要一些配置就能构建镜像,而部署则用一条run命令 [图片] 虚拟机 vs 容器 虚拟机需要有额外的虚拟机管理应用和虚拟机操作系统层,操作系统层不仅占用空间而且运行速度也相对慢 docker容器是在本机操作系统层面上实现虚拟化,因此很轻量,速度接近原生系统速度 [图片] 虚拟机启动速度是分钟级别,性能较弱、内存和硬盘占用大,一个物理机最多跑几十个虚拟机,但它的隔离性比较好。 docker启停都是秒级实现,内存和硬盘占用非常小,单机支持上千个容器,在ibm服务器上可运行上万个容器 容器跟虚机相比,有着巨大的优势 [图片] docker优点 只关心应用:以往我们需要关心操作系统、软件、项目,有了docker我们可以只关心应用而不是操作系统,docker发展迅速,基于docker的paas平台也层出不穷,使得我们能更方便的使用docker 快速交付:docker可在秒级提供沙箱环境,开发,测试,运维使用完全相同的环境来部署代码 微服务:docker有助于将一个复杂系统分解,让用户用更离散的方式思考服务 离线开发:将服务编排在笔记本中移动办公,使用docker可在本机秒级别启动一个本地开发环境 降低调试成本:在测试和上线时产生无效的类、有问题的依赖、缺少的配置等问题,docker可让一个问题调试和环境重现变得更简单 CD:docker让持续交付实现变得更容易,特别是对于蓝绿部署就更简单。 第一版上线时,需要上第二版新功能,两个版本功能会有冲突,这时用docker实现蓝绿部署就非常方便了 如:可以部署两个版本同时在线,新版本测试没问题了把老版本流量切到新版本就可以了 迁移:可以很快的迁移到其他云或服务器 与传统虚拟机方式相比,容器化方式在很多场景下都是存在极为明显的优势。无论是开发、测试、运维都应该尽快掌握docker,尽早享受其带来的巨大便利 [图片] 容器化方式在很多场景下都有极大的优势。无论是开发、测试、运维都应该尽快掌握docker,尽早享受其带来的巨大便利 [图片] 概念再来了解docker非常关键的概念,这样才能理解docker容器整个生命周期 [图片] 概念—镜像 镜像(类)=文件系统+数据,我常常用开发语言中的类比作镜像,对象比作容器 镜像由多个层加上一些docker元数据组成,容器运行着由镜像定义的系统 [图片] 概念—容器容器(对象)=镜像运行实例 容器是镜像的运行实例,可以使用同一个镜像运行多个实例。如图所示,一个ubuntu docker镜像产生了三个ubuntu容器,docker利用容器运行和隔离应用 [图片] 从读写角度来说,镜像是只读的,容器是在镜像上添加了一层可读写的文件系统 [图片] [图片] 概念—层层=文件变更集合 像传统虚机应用,每个应用都需要拷贝一份文件副本,运行成百上千上磁盘空间会迅速耗光,而docker采用写时复制来减少磁盘空间,当一个运行中的容器要写入一个文件时,它会把该文件复制到新区域来记录这次的修改,在执行docker提交时将这次修改记录下并产生一个新的层。docker分层解决大规模使用容器时碰到的磁盘和效率问题 [图片] 概念—仓库docker借鉴了大量git优秀的经验。docker仓库分公有库和私有库,最大的公开仓库是docker hub,国内也有很多仓库源 [图片] 2. 创建第一个docker应用通过创建一个docker应用来看看docker是怎么方便使用的 创建docker镜像方式 创建docker有四种方式 [图片] 但最常用的docker命令+手工提交和Dockerfile的方式 [图片] 对于我们来说Dockerfile是最常用也是最有用的 “dockerfile” [图片] 那创建一个docker应用只需要三步:编写dockerfile、构建镜像、运行容器 编写dockerfile那我们就开始用dockerfile来创建一个应用 Dockerfile是包含一系列命令的文本文件,这个文件包含6条命令 1、FROM是使用php官方镜像,左边是镜像名字,右边是标签名字,标签名字不写默认是latest 2、声明维护人员 3、RUN运行一条linux命令,我们把php代码重定向到/tmp/index.php 4、EXPOSE声明要开放的端口 5、WORKDIR启动容器后默认目录 6、CMD容器启动后,默认执行的命令,相当于应用的入口,用php自带的webserver监听8000 [图片] 构建镜像使用docker build命令生成镜像,—tag指定镜像的名字,左边是名字,右边是标签,最后有个.表示在当前目录查找Dockerfile 可以看到,每个命令都会有个输入输出,输入是命令,输出是给到层的id,所以,基本上每个命令都会产生一个层 最后提示镜像构建成功,并打上镜像标签 [图片] 运行容器第三,使用docker run命令运行镜像,-p将容器的8000端口映射到本机8000端口,—name给容器起个名字 用curl对本机8000端口请求,服务器返回当前时间,说明我们构建的容器运行成功了 [图片] 请求本地8000端口,服务器返回当前时间 [图片] dockerfile常用命令其实Dockerfile常用命令就5个:from、add、run、workdir、cmd 创建docker应用步骤编写dockerfile 构建镜像 运行容器 使用docker应用步骤拉取镜像 运行容器 dockerfile最佳实践精简镜像用途 尽量让每个镜像的用途单一 选择合适基础镜像 选择以alpine、busybox等基础的镜像 busybox:号称操作系统里的瑞士军刀,只有……这么大,但却有一百多常用命令 如果你的目标是小而精,busybox是首选,因为它已经精简到没有bash,使用的是ash,一个兼容posix的shell [图片] Alpine:你的目标是小但是又有一些工具的话,可以选择alpine,它是一个面向安全的轻量linux发行版,它关注安全、性能和资源效能,比busybox功能更完善,还提供apk查询和安装软件包,大小只有2-3兆 [图片] 很多官方的镜像都有alpine的镜像,像刚刚使用的php镜像 [图片] 提供维护者信息 正确使用版本 使用明确的版本号,而非依赖于默认的latest,避免环境不一致导致的问题 [图片] 删除临时文件 如安装软件后的安装包,如上图2、3步骤 提高生成速度 如内容不变的指令尽量放在前面,这样可以复用 减少镜像层数 多条命令写在一起,使生成的镜像层数少,如上图2、3步骤 恰当使用multi-stage 保证最终生成镜像最小化 3. 常用命令search想使用一个镜像,用这个命令就可以了,默认按评分排序 official如果是ok表示是官方镜像 Auto标示它是否用dickerfile进行自动化镜像构建 [图片] pull一旦确定一个镜像,通过对其名称执行docker pull来下载 标签默认是latest,严格来讲,镜像的仓库名还应该添加仓库地址的,默认是registry.hub.docker.com Docker images命令查找下载的镜像 [图片] run使用docker run运行一个容器,it表示用交互式方式运行,最后表示要执行的命令 [图片] 其实更常用的方式是以后台方式来执行,这时用d参数在后台运行 运行后用exec命令进去到容器 [图片] tagDocker tag给镜像一个新tag名字 Docker images查看centos镜像,把centos:latest打上centos:yeedomliu,这时再看会有3个centos,latest和yeedomliu的镜像id是相同的 把centos:yeedomliu删除,再查看latest还会存在,最后用rmi命令删除latest就会真正把latest镜像删除掉 如果相同镜像存在多个标签,只有最后一次的rmi命令会真正删除镜像 [图片] psPs可以查看运行中的容器 [图片] rmi删除一个镜像,同一个镜像id的不同标签的镜像,使用rmi删除最后一个镜像才会真正删除这个镜像 [图片] rm删除docker容器,如果运行中的容器需要加-f [图片] diff容器启动后文件变化情况 [图片] logs查看容器运行后的日志 [图片] cp我们想从容器里面拷贝文件到宿主机,或相反的过程就可以用到cp命令 [图片] container prune随着使用docker时间越长,停止状态下的容器会越来越多,这些都会占据磁盘空间 [图片] image prune未被打标签的镜像可以用image prune命令清理 [图片] system prune/df如果你觉得刚刚两条命令执行起来麻烦,可以用docker system prune一条命令搞定 另外用system df查看docker磁盘空间 [图片] 4. 实战了解了docker基础知识后,可进入相对实战的环节 本地开发 常见问题 架构 优化 本地开发 我们的项目使用了很多服务,如redis/mysql/mongodb等等,如果一个个运行起来,还加上配置,容易出手,也比较麻烦 kitematic:与使用命令行管理本地容器相比,你更想使用图形工具对容器管理,官方推出的容器管理工具,通过它可以查找镜像、创建容器、配置、启停容器等管理 [图片] [图片] 这是配置容器端口和宿主机端口,目录,网络等映射界面 [图片] docker-composecompose定位是“定义和运行多个docker容器的应用”,前身fig,目前仍然兼容fig格式的模板文件。 一条命令可以把一个复杂的应用启动起来 日常工作中,经常碰到多个容器相互完成某项任务 [图片] docker-compose示例1 默认模板文件名叫docker-compose.yml,结构很简单,每个顶级元素为服务名称,次级信息为配置信息 这里使用了redis/mongodb/mysql/nginx镜像,分别给它们映射了本地目录、端口、密码等信息,nginx镜像需要使用redis/mysql等服务,用links命令连接进来 [图片] docker-compose示例2 如果在本地开发,每个项目都可以像之前说的那样配置,这里提供了另外一种做法 我把公共的资源在一开始就启动,每个项目里只启动nginx镜像并关联其它的服务即可 公共服务compose [图片] 项目compose [图片] 常见问题 主进程:docker启动第一个进程称主进程,就是id为1的进程,这个进程退出就意味着容器退出,所以想要使docker作为服务使用,这个进程是不能退出的 expose命令是声明暴露的端口,运行时用-P才会生效。一般ports命令是做真正的端口映射,比较常用 架构 安装了docker的主机,一般在一个私有网络上 1、调用docker客户端可以从守护进程获取信息或发送指令 2、docker守护进程使用http协议接收来自docker客户端的请求 3、私有docker注册中心存储docker镜像 4、docker hub是由docker公司运营的最大的公共注册中心 互联网上也存在其他公共的注册中心 调用 Docker客户端可以从守护进程获取信息或给它发送指令。守护进程是一个服务器,它使用 HTTP协议接收来自客户端的请求并返回响应。相应地,它会向其他服务发起请求来发送和接收镜像,使用的同样是 HTTP协议。该服务器将接收来自命令行客户端或被授权连接的任何人的请求。守护进程还负责在幕后处理用户的镜像和容器,而客户端充当的是用户与 REST风格 API之间的媒介。 理解这张图的关键在于,当用户在自己的机器上运行 Docker时,与其进行交互的可能是自己机器上的另一个进程,或者甚至是运行在内部网络或互联网上的服务。 [图片] 优化 使用小镜像:一般来说,使用小的镜像都相对比较优秀,如官方的镜像基本上都有基于alpine的镜像 事后清理:删除镜像里软件包或一些临时文件,减小镜像大小 命令写一行:多个命令尽量写在一起有助于减少层数,也会减少镜像的大小 脚本安装:使用脚本进行初始化时,可以有效减少dockerfile的命令,同时带来另外的问题,可读性不好并且构建镜像时缓存不了 扁平化镜像:构建镜像过程中,可能会涉及到一些敏感信息,或者用了上面的办法镜像依然很大,可以试试这个办法 docker export 容器名或容器id | docker import - 镜像标签 multi-stage:从docker 17.05版本开始,docker支持multi-stage(多阶段构建),特别适合编译型语言,如我在一个镜像下编译,在另外一个很小的系统运行,如下图,go项目在golang环境下编译,在alpine环境下运行 [图片]
2019-03-27 - 微信小程序精品demo:仿网易云音乐
目前实现功能 [代码]用户歌单 歌单详情 FM 音乐播放(暂停,上下一首,歌词) 评论显示 MV [代码] 待实现功能(需登录状态) [代码]登录 歌曲喜欢,FM trash 增加评论,评论点赞等 歌手详情 DJ 收藏歌单,收藏单曲 [代码] 主体登录功能没有实现,登录方法已经有,希望有会nodejs的小伙伴得空帮忙,假如同学帮忙解决了,可以回复在回帖内,不胜感激! [图片][图片][图片][图片] 仿网易云音乐demo下载地址:http://t.cn/EJqmdq5 精品demo实战项目下载地址:http://t.cn/ExJMe95
2019-03-25 - setData 学问多
为什么不能频繁 setData 先科普下 setData 做的事情: 在数据传输时,逻辑层会执行一次 JSON.stringify 来去除掉 setData 数据中不可传输的部分,之后将数据发送给视图层。同时,逻辑层还会将 setData 所设置的数据字段与 data 合并,使开发者可以用 this.data 读取到变更后的数据。 因此频繁调用,视图会一直更新,阻塞用户交互,引发性能问题。 但频繁调用是常见开发场景,能不能频繁调用的同时,视图延迟更新呢? 参考 Vue,我们能知道,Vue 每次赋值操作并不会直接更新视图,而是缓存到一个数据更新队列中,异步更新,再触发渲染,此时多次赋值,也只会渲染一次。 [代码]let newState = null; let timeout = null; const asyncSetData = ({ vm, newData, }) => { newState = { ...newState, ...newData, }; clearTimeout(timeout); timeout = setTimeout(() => { vm.setData({ ...newState, }); newState = null }, 0); }; [代码] 由于异步代码会在同步代码之后执行,因此,当你多次使用 asyncSetData 设置 newState 时,newState 都会被缓存起来,并异步 setData 一次 但同时,这个方案也会带来一个新的问题,同步代码会阻塞页面的渲染。 同步代码会阻塞页面的渲染的问题其实在浏览器中也存在,但在小程序中,由于是逻辑、视图双线程架构,因此逻辑并不会阻塞视图渲染,这是小程序的优点,但在这套方案将会丢失这个优点。 鱼与熊掌不可兼得也! 对于信息流页面,数据过多怎么办 单次设置的数据不能超过 1024kB,请尽量避免一次设置过多的数据 通常,我们拉取到分页的数据 newList,添加到数组里,一般是这么写: [代码]this.setData({ list: this.data.list.concat(newList) }) [代码] 随着分页次数的增加,list 会逐渐增大,当超过 1024 kb 时,程序会报 [代码]exceed max data size[代码] 错误。 为了避免这个问题,我们可以直接修改 list 的某项数据,而不是对整个 list 重新赋值: [代码]let length = this.data.list.length; let newData = newList.reduce((acc, v, i)=>{ acc[`list[${length+i}]`] = v; return acc; }, {}); this.setData(newData); [代码] 这看着似乎还有点繁琐,为了简化操作,我们可以把 list 的数据结构从一维数组改为二维数组:[代码]list = [newList, newList][代码], 每次分页,可以直接将整个 newList 赋值到 list 作为一个子数组,此时赋值方式为: [代码]let length = this.data.list.length; this.setData({ [`list[${length}]`]: newList }); [代码] 同时,模板也需要相应改成二重循环: [代码]<block wx:for="{{list}}" wx:for-item="listItem" wx:key="{{listItem}}"> <child wx:for="{{listItem}}" wx:key="{{item}}"></child> </block> [代码] 下拉加载,让我们一夜回到解放前 信息流产品,总避免不了要做下拉加载。 下拉加载的数据,需要插到 list 的最前面,所以我们应该这样做: [代码]this.setData({ `list[-1]`: newList }) [代码] 哦不,对不起,上面是错的,应该是下面这样: [代码]this.setData({ list: this.data.list.unshift(newList) }); [代码] 这下好,又是一次性修改整个数组,一夜回到解放前… 为了解决这个问题,这里需要一点奇淫巧技: 为下拉加载维护一个单独的二维数组 pullDownList 在渲染时,用 wxs 将 pullDownList reverse 一下 此时,当下拉加载时,便可以只修改数组的某个子项: [代码]let length = this.data.pullDownList.length; this.setData({ [`pullDownList[${length}]`]: newList }); [代码] 关键在于渲染时候的反向渲染: [代码]<wxs module="utils"> function reverseArr(arr) { return arr.reverse() } module.exports = { reverseArr: reverseArr } </wxs> <block wx:for="{{utils.reverseArr(pullDownList)}}" wx:for-item="listItem" wx:key="{{listItem}}"> <child wx:for="{{listItem}}" wx:key="{{item}}"></child> </block> [代码] 问题解决! 参考资料 终极蛇皮上帝视角之微信小程序之告别 setData, 佯真愚, 2018年08月12日
2019-04-11 - 【优化】小程序优化-代码篇
本文主要是从代码方面跟大家分享我自己在开发小程序的一些做法,希望能帮到一些同学。 前言 不知道大家有没有这种体会,刚到公司时,领导要你维护之前别人写的代码,你看着别人写的代码陷入了深深的思考:“这谁写的代码,这么残忍” [图片] 俗话说“不怕自己写代码,就怕改别人的代码”,一言不和就改到你吐血,所以为了别人好,也为了自己好,代码规范,从我做起。 项目目录结构 在开发之前,首先要明确你要做什么,不要一上来就是干,咱们先把项目结构搭好。一般来说,开发工具初始化的项目基本可以满足需求,如果你的项目比较复杂又有一定的结构的话就要考虑分好目录结构了,我的做法如下图: [图片] component文件夹是放自定义组件的 pages放页面 public放公共资源如样式表和公共图标 units放各种公共api文件和封装的一些js文件 config.js是配置文件 这么分已经足以满足我的需求,你可以根据自己的项目灵活拆分。 配置文件 我的项目中有个config.js,这个文件是用来配置项目中要用到的一些接口和其它私有字段,我们知道在开发时通常会有测试环境和正式环境,而测试环境跟正式环境的域名可能会不一样,如果不做好配置的话直接写死接口那等到上线的时候一个个改会非常麻烦,所以做好配置是必需的,文件大致如下: [图片] 首先是定义域名,然后在config对象里定义接口名称,getAPI(key)是获取接口方法,最后通过module暴露出去就可以了.引用的时候只要在页面引入 import domain from ‘…/…/config’;,然后wx.request的时候url的获取方式是domain.getAPI(’’) 代码健壮性、容错性 例子 代码的健壮性、容错性也是我们应该要考虑的一点,移动端的项目不像pc端的网络那么稳定,很多时候网络一不稳定就决定我们的项目是否能正常运行,而一个好的项目就一定要有良好的容错性,就是说在网络异常或其它因素导致我们的项目不能运行时程序要有一个友好的反馈,下面是一个网络请求的例子: [图片] 相信多数人请求的方式是这样,包括我以前刚接触小程序的时候也是这样写,这样写不是说不好,而是不太严谨,如果能够正常获取数据那还好,但是一旦请求出现错误那程序可以到此就没法运行下去了,有些比较好的会加上faill失败回调,但也只是请求失败时的判断,在请求成功到获取数据的这段流程内其实是还有一些需要我们判断的,一般我的做法是这样: [图片] 在请求成功后小程序会进行如下判断: 判断是否返回200,是则进行一下步操作,否则抛出错误 判断数据结构是否完整,是则进行一下步操作,否则抛出错误 然后就可以在页面根据情况进行相应的操作了。 定制错误提示码 可以看到上面的截图的错误打印后面会带一个gde0或gde1的英文代码,这个代码是干嘛用的呢,其实是用来报障的,当我们的小程序上线后可能会遇到一些用户发来的报障,一般是通过截图发给我们,之前没有做错误提示码的时候可能只是根据一句错误提示来定位错误,但是很多时候误提示语都是一样的,我们根本不知道是哪里错了,这样一来就不能很快的定位的错误,所以加上这样一个提示码,到时用户一发截图来,我们只要根据这个错误码就能很快的定位错误并解决了,错误提示码建议命名如下: 不宜过长,3个字母左右 唯一性 意义明确 像上面gde表示获取草稿失败,后面加上数字表示是哪一步出错。 模块化 我们组内的大神说过, 模块化的意义在义分治,不在于复用。 之前我以为模块化只是为了可以复用,其实不然,无论模块多么小也是可以模块化,哪怕只是一个简单的样式也一样,并是不为了复用,而是管理起来方便。 很多同学经常将一些公共的样式事js放在app.wxss和app.js里以便调用,这样做其实有一个坏处,就是维护性比较差,如果是比较小的项目还好,项目一大问题就来了。而且项目是会迭代的,不可能总是一个人开发,可能后面会交接给其他人开发,所以会造成的问题就是: app.wxss和app.js里的内容只会越来越多,因为别人不确定哪些是没用的也不敢删,只能往里加东西,造成文件臃肿,不利于维护。 app.wxss和app.js对于每个页面都有效,可读性方面比较差。 所以模块化的意义就出来了,将公共的部分进行模块化统一管理,也便于维护。 样式模块化 公共样式根据上面的目录结构我是放在public里的css里,每个文件命名好说明是哪个部分的模块化,比如下面这个就表示一个按钮的模块化 [图片] 前面说过模块化不在于大小,就算只是一个简单的样式也可以进行模块化,只要在用到的地方import一下就行了,就知道哪里有用到,哪里没有用到,清晰明了。 js模块化 js模块化这里分为两个部分的模块化,一部分是公共js的模块化,另一部分是页面js的模块化即业务与数据的拆分。 公共js模块化 比较常用的公共js有微信登录,弹窗,请求等,一般我是放在units文件夹里,这里经微信弹窗api为例: [图片] 如图是在小程序中经常会用到的弹窗提示,这里进行封装,定义变量,只要在页面中引入就能直接调用了,不用每次都写一大串。比如在请求的时候是这样用的 [图片] toast()就是封装的弹窗api,这样看起来是不是清爽多了! 业务与数据模块化 业务与数据模块化就是指业务和数据分开,互不影响,业务只负责业务,数据只负责数据,可以看到页面会比普通的页面多了一个api.js [图片] 这个文件主要就是用来获取数据的,而index.js主要用来处理数据,这样分工明确,相比以往获取数据和处理数据都在一个页面要好很多,而且我这里获取数据是返回一个promise对象的,也方便处理一些异步操作。 组件化 组件化相信大家都不陌生了,自从小程序支持自定义组件,可以说是大大地提高了开发效率,我们可以将一些公共的部分进行组件化,这部分就不详细介绍,大家可以去看文档。组件化对于我们的项目来说有很大的好处,而且组件化的可移植性强,从一个项目复用到另一个项目基本不需要做什么改动。 总结 这篇文章通过我自己的一些经验来给大家介绍如何优化自己的代码,主要有以下几点 分好项目目录结构 做好接口配置文件 代码健壮性、容错性的处理 定制错误提示码方便定位错误 样式模块化和js模块化 组件化 最后放上项目目录结构的代码片段,大家可以研究一下,有问题一起探讨:https://developers.weixin.qq.com/s/1uVHRDmT7j6l
2019-03-07 - 可能是目前最全的koa源码解析指南
本文将按照以下顺序讲解koa,通过初读到精读的方式,一步一步讲解koa涉及的相关知识。 通过阅读完本文,你将了解以下内容: koa框架核心 类继承在koa中的应用 co的实现原理,是如何将generator转为async函数的 洋葱模型中间件实现原理 koa的统一错误处理机制 委托模式在koa中的应用 一、koa是什么 koa是一个精简的node框架,它主要做了以下事情: 基于node原生req和res为request和response对象赋能,并基于它们封装成一个context对象。 基于async/await(generator)的中间件洋葱模型机制。 koa1和koa2在源码上的区别主要是于对异步中间件的支持方式的不同。 koa1是使用generator、yield)的模式。 koa2使用的是async/await+Promise的模式。下文主要是针对koa2版本源码上的讲解。 二、初读koa源码 如果你看了koa的源码,会发现koa源码其实很简单,共4个文件。 [代码]── lib ├── application.js ├── context.js ├── request.js └── response.js [代码] 这4个文件其实也对应了koa的4个对象: [代码]── lib ├── new Koa() || ctx.app ├── ctx ├── ctx.req || ctx.request └── ctx.res || ctx.response [代码] 下面,我们先初步了解koa的源码内容,读懂它们,可以对koa有一个初步的了解。 2.1 application.js application.js是koa的入口(从koa文件夹下的package.json的main字段(lib/application.js)中可以得知此文件是入口文件),也是核心部分。 下面对核心代码进行了注释。 [代码]/** * 依赖模块,包括但不止于下面的,只列出核心需要关注的内容 */ const response = require('./response'); const compose = require('koa-compose'); const context = require('./context'); const request = require('./request'); const Emitter = require('events'); const convert = require('koa-convert'); /** * 继承Emitter,很重要,说明Application有异步事件的处理能力 */ module.exports = class Application extends Emitter { constructor() { super(); this.middleware = []; // 该数组存放所有通过use函数的引入的中间件函数 this.subdomainOffset = 2; // 需要忽略的域名个数 this.env = process.env.NODE_ENV || 'development'; // 通过context.js、request.js、response.js创建对应的context、request、response。为什么用Object.create下面会讲解 this.context = Object.create(context); this.request = Object.create(request); this.response = Object.create(response); } // 创建服务器 listen(...args) { debug('listen'); const server = http.createServer(this.callback()); //this.callback()是需要重点关注的部分,其实对应了http.createServer的参数(req, res)=> {} return server.listen(...args); } /* 通过调用koa应用实例的use函数,形如: app.use(async (ctx, next) => { await next(); }); 来加入中间件 */ use(fn) { if (isGeneratorFunction(fn)) { fn = convert(fn); // 兼容koa1的generator写法,下文会讲解转换原理 } this.middleware.push(fn); // 将传入的函数存放到middleware数组中 return this; } // 返回一个类似(req, res) => {}的函数,该函数会作为参数传递给上文的listen函数中的http.createServer函数,作为请求处理的函数 callback() { // 将所有传入use的函数通过koa-compose组合一下 const fn = compose(this.middleware); const handleRequest = (req, res) => { // 基于req、res封装出更强大的ctx,下文会详细讲解 const ctx = this.createContext(req, res); // 调用app实例上的handleRequest,注意区分本函数handleRequest return this.handleRequest(ctx, fn); }; return handleRequest; } // 处理请求 handleRequest(ctx, fnMiddleware) { // 省略,见下文 } // 基于req、res封装出更强大的ctx createContext(req, res) { // 省略,见下文 } }; [代码] 从上面代码中,我们可以总结出application.js核心其实处理了这4个事情: 1. 启动框架 2. 实现洋葱模型中间件机制 3. 封装高内聚的context 4. 实现异步函数的统一错误处理机制 2.2 context.js [代码]const util = require('util'); const createError = require('http-errors'); const httpAssert = require('http-assert'); const delegate = require('delegates'); const proto = module.exports = { // 省略了一些不甚重要的函数 onerror(err) { // 触发application实例的error事件 this.app.emit('error', err, this); }, }; /* 在application.createContext函数中, 被创建的context对象会挂载基于request.js实现的request对象和基于response.js实现的response对象。 下面2个delegate的作用是让context对象代理request和response的部分属性和方法 */ delegate(proto, 'response') .method('attachment') ... .access('status') ... .getter('writable') ...; delegate(proto, 'request') .method('acceptsLanguages') ... .access('querystring') ... .getter('origin') ...; [代码] 从上面代码中,我们可以总结出context.js核心其实处理了这2个事情: 1. 错误事件处理 2. 代理response对象和request对象的部分属性和方法 2.3 request.js [代码]module.exports = { // 在application.js的createContext函数中,会把node原生的req作为request对象(即request.js封装的对象)的属性 // request对象会基于req封装很多便利的属性和方法 get header() { return this.req.headers; }, set header(val) { this.req.headers = val; }, // 省略了大量类似的工具属性和方法 }; [代码] request对象基于node原生req封装了一系列便利属性和方法,供处理请求时调用。 所以当你访问ctx.request.xxx的时候,实际上是在访问request对象上的赋值器(setter)和取值器(getter)。 2.4 response.js [代码]module.exports = { // 在application.js的createContext函数中,会把node原生的res作为response对象(即response.js封装的对象)的属性 // response对象与request对象类似,基于res封装了一系列便利的属性和方法 get body() { return this._body; }, set body(val) { // 支持string if ('string' == typeof val) { } // 支持buffer if (Buffer.isBuffer(val)) { } // 支持stream if ('function' == typeof val.pipe) { } // 支持json this.remove('Content-Length'); this.type = 'json'; }, } [代码] response对象与request对象类似,就不再赘述。 值得注意的是,返回的body支持Buffer、Stream、String以及最常见的json,如上示例所示。 三、深入理解koa源码 通过上面的阅读,相信对koa有了一个初步认识,但毕竟是走马观花,本着追根问底的学术精神,还需要对大量细节进行揣摩,下文会从初始化、启动应用、处理请求等的角度,来对这过程中比较重要的细节进行讲解及延伸,如果彻底弄懂,会对koa以及ES6、generator、async/await、co、异步中间件等有更深一步的了解。 3.1 初始化 koa实例化: [代码]const Koa = require('koa'); const app = new Koa(); [代码] koa执行源码: [代码]module.exports = class Application extends Emitter { constructor() { super(); this.proxy = false; this.middleware = []; this.subdomainOffset = 2; this.env = process.env.NODE_ENV || 'development'; this.context = Object.create(context); //为什么要使用Object.create? 见下面原因 this.request = Object.create(request); this.response = Object.create(response); if (util.inspect.custom) { this[util.inspect.custom] = this.inspect; } } } [代码] 当实例化koa的时候,koa做了以下2件事: 继承Emitter,具备处理异步事件的能力。然而koa是如何处理,现在还不得而知,这里打个问号。 在创建实例过程中,有三个对象作为实例的属性被初始化,分别是context、request、response。还有我们熟悉的存放中间件的数组mddleware。这里需要注意,是使用Object.create(xxx)对this.xxx进行赋值。 Object.create(xxx)作用: 根据xxx创建一个新对象,并且将xxx的属性和方法作为新的对象的proto。 举个例子,代码在demo02: [代码]const a = { name: 'rose', getName: function(){ return 'rose' } }; const b = Object.create(a); console.log('a is ', a); console.log('b is ', b); [代码] 结果如下: [图片] 可以看到,a的属性和方法已经挂载在b的原型(proto)下了。 所以,当执行完 [代码]this.context = Object.create(context); this.request = Object.create(request); this.response = Object.create(response); [代码] 的时候,以context为例,其实是创建一个新对象,使用context对象来提供新创建对象的proto,并且将这个对象赋值给this.context,实现了类继承的作用。为什么不直接用this.context=context呢?我的理解是,这样会导致两者指向同一片内存,而不是实现继承的目的。 3.2 启动应用及处理请求 在实例化koa之后,接下来,使用app.use传入中间件函数, [代码]app.use(async (ctx,next) => { await next(); }); [代码] koa对应执行源码: [代码] use(fn) { if (isGeneratorFunction(fn)) { fn = convert(fn); } this.middleware.push(fn); return this; } [代码] 当我们执行app.use的时候,koa做了这2件事情: 判断是否是generator函数,如果是,使用koa-convert做转换(koa3将不再支持generator)。 所有传入use的方法,会被push到middleware中。 这里做下延伸讲解,如何将generator函数转为类async函数。 如何将generator函数转为类async函数 koa2处于对koa1版本的兼容,中间件函数如果是generator函数的话,会使用koa-convert进行转换为“类async函数”。(不过到第三个版本,该兼容会取消)。 那么究竟是怎么转换的呢? 我们先来想想generator和async有什么区别? 唯一的区别就是async会自动执行,而generator每次都要调用next函数。 所以问题变为,如何让generator自动执行next函数? 回忆一下generator的知识:每次执行generator的next函数时,它会返回一个对象: [代码]{ value: xxx, done: false } [代码] 返回这个对象后,如果能再次执行next,就可以达到自动执行的目的了。 看下面的例子: [代码]function * gen(){ yield new Promise((resolve,reject){ //异步函数1 if(成功){ resolve() }else{ reject(); } }); yield new Promise((resolve,reject){ //异步函数2 if(成功){ resolve() }else{ reject(); } }) } let g = gen(); let ret = g.next(); [代码] 此时ret = { value: Promise实例; done: false};value已经拿到了Promise对象,那就可以自己定义成功/失败的回调函数了。如: [代码]ret.value.then(()=>{ g.next(); }) [代码] 现在就大功告成啦。我们只要找到一个合适的方法让g.next()一直持续下去就可以自动执行了。 所以问题的关键在于yield的value必须是一个Promise。那么我们来看看co是如何把这些都东西都转化为Promise的: [代码]function co(gen) { var ctx = this; // 把上下文转换为当前调用co的对象 var args = slice.call(arguments, 1) // 获取参数 // we wrap everything in a promise to avoid promise chaining, // 不管你的gen是什么,都先用Promise包裹起来 return new Promise(function(resolve, reject) { // 如果gen是函数,则修改gen的this为co中的this对象并执行gen if (typeof gen === 'function') gen = gen.apply(ctx, args); // 因为执行了gen,所以gen现在是一个有next和value的对象,如果gen不存在、或者不是函数则直接返回gen if (!gen || typeof gen.next !== 'function') return resolve(gen); // 执行类似上面示例g.next()的代码 onFulfilled(); function onFulfilled(res) { var ret; try { ret = gen.next(res); // 执行每一个gen.next() } catch (e) { return reject(e); } next(ret); //把执行得到的返回值传入到next函数中,next函数是自动执行的关键 } function onRejected(err) { var ret; try { ret = gen.throw(err); } catch (e) { return reject(e); } next(ret); } /** * Get the next value in the generator, * return a promise. */ function next(ret) { // 如果ret.done=true说明迭代已经完毕,返回最后一次迭代的value if (ret.done) return resolve(ret.value); // 无论ret.value是什么,都转换为Promise,并且把上下文指向ctx var value = toPromise.call(ctx, ret.value); // 如果value是一个Promise,则继续在then中调用onFulfilled。相当于从头开始!! if (value && isPromise(value)) return value.then(onFulfilled, onRejected); return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, ' + 'but the following object was passed: "' + String(ret.value) + '"')); } }); } [代码] 请留意上面代码的注释。 从上面代码可以得到这样的结论,co的思想其实就是: 把一个generator封装在一个Promise对象中,然后再这个Promise对象中再次把它的gen.next()也封装出Promise对象,相当于这个子Promise对象完成的时候也重复调用gen.next()。当所有迭代完成时,把父Promise对象resolve掉。这就成了一个类async函数了。 以上就是如何把generator函数转为类async的内容。 好啦,我们继续回来看koa的源码。 当执行完app.use时,服务还没启动,只有当执行到app.listen(3000)时,程序才真正启动。 koa源码: [代码]listen(...args) { const server = http.createServer(this.callback()); return server.listen(...args); } [代码] 这里使用了node原生http.createServer创建服务器,并把this.callback()作为参数传递进去。可以知道,this.callback()返回的一定是这种形式:(req, res) => {}。继续看下this.callback代码。 [代码]callback() { // compose处理所有中间件函数。洋葱模型实现核心 const fn = compose(this.middleware); // 每次请求执行函数(req, res) => {} const handleRequest = (req, res) => { // 基于req和res封装ctx const ctx = this.createContext(req, res); // 调用handleRequest处理请求 return this.handleRequest(ctx, fn); }; return handleRequest; } handleRequest(ctx, fnMiddleware) { const res = ctx.res; res.statusCode = 404; // 调用context.js的onerror函数 const onerror = err => ctx.onerror(err); // 处理响应内容 const handleResponse = () => respond(ctx); // 确保一个流在关闭、完成和报错时都会执行响应的回调函数 onFinished(res, onerror); // 中间件执行、统一错误处理机制的关键 return fnMiddleware(ctx).then(handleResponse).catch(onerror); } [代码] 从上面源码可以看到,有这几个细节很关键: 1. compose(this.middleware)做了什么事情(使用了koa-compose包)。 2. 如何实现洋葱式调用的? 3. context是如何处理的?createContext的作用是什么? 4. koa的统一错误处理机制是如何实现的? 下面,来进行一一讲解。 koa-compose和洋葱式调用 先看第一、二个问题。 看看koa-compose的精简源码: [代码]module.exports = compose function compose(middleware) { return function (context, next) { //略 } } [代码] compose函数接收middleware数组作为参数,middleware中每个对象都是async函数,返回一个以context和next作为入参的函数,我们跟源码一样,称其为fnMiddleware。 在外部调用this.handleRequest的最后一行,运行了中间件: [代码]fnMiddleware(ctx).then(handleResponse).catch(onerror); [代码] 我们来看下fnMiddleware究竟是怎么实现的: [代码]function compose (middleware) { return function (context, next) { let index = -1 return dispatch(0) function dispatch (i) { if (i <= index) return Promise.reject(new Error('next() called multiple times')) index = i let fn = middleware[i] if (i === middleware.length) fn = next if (!fn) return Promise.resolve() try { return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); } catch (err) { return Promise.reject(err) } } } } [代码] 解释之前,我们通过一个例子来理解,假设加入了两个中间件。源码在demo01: [代码]const Koa = require('koa'); const app = new Koa(); app.use(async (ctx,next) => { console.log("1-start"); await next(); console.log("1-end"); }); app.use(async (ctx, next) => { console.log("2-start"); await next(); console.log("2-end"); }); app.listen(3000); [代码] 我们逐步执行, 0:fnMiddleware(ctx)运行; 0:执行dispatch(0); 0:进入dispatch函数,下图是各个参数对应的值。 [图片] 从参数的值可以得知,最终会执行这段代码: [代码]return Promise.resolve(fn(context, function next() { return dispatch(i + 1) })) [代码] 此时,fn就是第一个中间件,它是一个async函数,async函数会返回一个Promise对象,Promise.resolve()中若传入一个Promise对象的话,那么Promise.resolve将原封不动地返回这个Promise对象。 0:进入到第一个中间件代码内部,先执行“console.log(“1-start”)” 0:然后执行“await next()”,并开始等待next执行返回 1:进入到next函数后,执行的是dispatch(1),于是老的dispatch(0)压栈,开始从头执行dispatch(1),即把第二个中间件函数交给fn,然后开始执行,这就完成了程序的控制权从第一个中间件到第二个中间件的转移。下图是执行dispatch(1)时函数内变量的值: [图片] 1:进入到第二个中间件代码内部,先执行“console.log(“2-start”)”。然后执行“await next()”并等待next执行返回 2:进入next函数后,主要执行dispatch(2),于是老的dispatch(1)压栈,从头开始执行dispatch(2)。下图是执行dispatch(2)时函数内变量的值: [图片] 所以返回Promise.resolve(),此时第二个中间件的next函数返回了。 2:所以接下来执行“console.log(“2-end”)” 1:由此第二个中间件执行完成,把程序控制权交给第一个中间件。第一个中间件执行“console.log(“1-end”)” 0:终于完成了所有中间件的执行,如果中间没有异常,则返回Promise.resolve(),执行handleResponse回调;如有异常,则返回Promies.reject(err),执行onerror回调。 建议用上面例子进行调试源码来理解,会更加清晰。 至此,回答了上面提到的2个问题: compose(this.middleware)做了什么事情(使用了koa-compose包)。 如何实现洋葱式调用的? 单一context原则 接下来,我们再来看第3个问题context是如何处理的?createContext的作用是什么? context使用node原生的http监听回调函数中的req、res来进一步封装,意味着对于每一个http请求,koa都会创建一个context并共享给所有的全局中间件使用,当所有的中间件执行完后,会将所有的数据统一交给res进行返回。所以,在每个中间件中我们才能取得req的数据进行处理,最后ctx再把要返回的body给res进行返回。 请记住句话:每一个请求都有唯一一个context对象,所有的关于请求和响应的东西都放在其里面。 下面来看context(即ctx)是怎么封装的: [代码]// 单一context原则 createContext(req, res) { const context = Object.create(this.context); // 创建一个对象,使之拥有context的原型方法,后面以此类推 const request = context.request = Object.create(this.request); const response = context.response = Object.create(this.response); context.app = request.app = response.app = this; context.req = request.req = response.req = req; context.res = request.res = response.res = res; request.ctx = response.ctx = context; request.response = response; response.request = request; context.originalUrl = request.originalUrl = req.url; context.state = {}; return context; } [代码] 本着一个请求一个context的原则,context必须作为一个临时对象存在,所有的东西都必须放进一个对象,因此,从上面源码可以看到,app、req、res属性就此诞生。 请留意以上代码,为什么app、req、res、ctx也存放在了request、和response对象中呢? 使它们同时共享一个app、req、res、ctx,是为了将处理职责进行转移,当用户访问时,只需要ctx就可以获取koa提供的所有数据和方法,而koa会继续将这些职责进行划分,比如request是进一步封装req的,response是进一步封装res的,这样职责得到了分散,降低了耦合度,同时共享所有资源使context具有高内聚的性质,内部元素互相能访问到。 在createContext中,还有这样一行代码: [代码]context.state = {}; [代码] 这里的state是专门负责保存单个请求状态的空对象,可以根据需要来管理内部内容。 异步函数的统一错误处理机制 接下来,我们再来看第四个问题:koa的统一错误处理机制是如何实现的? 回忆一下我们如何在koa中统一处理错误,只需要让koa实例监听onerror事件就可以了。则所有的中间件逻辑错误都会在这里被捕获并处理。如下所示: [代码]app.on('error', err => { log.error('server error', err) }); [代码] 这是怎么做到的呢?核心代码如下(在上面提到的application.js的handleRequest函数中): [代码]handleRequest(ctx, fnMiddleware) { const res = ctx.res; res.statusCode = 404; // application.js也有onerror函数,但这里使用了context的onerror, const onerror = err => ctx.onerror(err); const handleResponse = () => respond(ctx); onFinished(res, onerror); // 这里是中间件如果执行出错的话,都能执行到onerror的关键!!! return fnMiddleware(ctx).then(handleResponse).catch(onerror); } [代码] 这里其实会有2个疑问: 1.出错执行的回调函数是context.js的onerror函数,为什么在app上监听onerror事件,就能处理所有中间件的错误呢? 请看下context.js的onerror: [代码]onerror(err) { this.app.emit('error', err, this); } [代码] 这里的this.app是对application的引用,当context.js调用onerror时,其实是触发application实例的error事件 。该事件是基于“Application类继承自EventEmitter”这一事实。 2.如何做到集中处理所有中间件的错误? 我们再来回顾一下洋葱模型的中间件实现源码: [代码]function compose (middleware) { return function (context, next) { let index = -1 return dispatch(0) function dispatch (i) { if (i <= index) return Promise.reject(new Error('next() called multiple times')) index = i let fn = middleware[i] if (i === middleware.length) fn = next if (!fn) return Promise.resolve() try { return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); } catch (err) { return Promise.reject(err) } } } } [代码] 还有外部处理: [代码]// 这里是中间件如果执行出错的话,都能执行到onerror的关键!!! return fnMiddleware(ctx).then(handleResponse).catch(onerror); [代码] 主要涉及这几个知识点: async函数返回一个Promise对象 async函数内部抛出错误,会导致Promise对象变为reject状态。抛出的错误会被catch的回调函数(上面为onerror)捕获到。 await命令后面的Promise对象如果变为reject状态,reject的参数也可以被catch的回调函数(上面为onerror)捕获到。 这样就可以理解为什么koa能实现异步函数的统一错误处理了。 委托模式 最后讲一下koa中使用的设计模式——委托模式。 当我们在使用context对象时,往往会这样使用: ctx.header 获取请求头 ctx.method 获取请求方法 ctx.url 获取请求url 这些对请求参数的获取都得益于context.request的许多属性都被委托在context上了 [代码]delegate(proto, 'request') .method('acceptsLanguages') ... .access('method') ... .getter('URL') .getter('header') ...; [代码] 又比如, ctx.body 设置响应体 ctx.status 设置响应状态码 ctx.redirect() 请求重定向 这些对响应参数的设置都得益于koa中的context.response的许多方法都被委托在context对象上了: [代码]delegate(proto, 'response') .method('redirect') ... .access('status') .access('body') ...; [代码] 至于delegate的使用和源码就不展开了,有兴趣的同学可以看这里 以上就是对koa源码所涉及的所有知识点的解析啦,初次看可能会有点晕,建议结合源码和例子一起多看几次,就会领会到koa框架的简洁和优雅之美所在了~ 参考文章: https://koajs.com https://zhuanlan.zhihu.com/p/34797505 https://zhuanlan.zhihu.com/p/24559011 https://juejin.im/entry/59e747f0f265da431c6f668e https://www.jianshu.com/p/45ec555a6c83 https://juejin.im/post/5b9339136fb9a05d3634ba13 https://www.jianshu.com/p/feb98591a1e5
2019-03-14 - 开发实战分享|小程序扫码获取图书信息(内附详细教程)
作者:祈澈姑娘 小程序扫码实现读取isbn,获取图书的各种信息 接触到云函数已经有一段时间了,之前一直在看api,现在自己跟着网络上的资料和视频学习,受到豆瓣读书系列的启发,决定用云函数做一个项目,获取图书信息并存入云数据库。 基本流程 [图片] 1.用户端小程序调用 wx.scanCode接口,获取到ISBN码。 2.使用ISBN码调用云函数,在请求云函数的时候,云函数会请求豆瓣的API,获取豆瓣图书信息。 3.图书信息请求到之后,会将其中无用的信息返回给小程序中,小程序中再拿出获取到的信息,创建图书条目。 4.将对应的数据直接存储到云开大的数据库里面。 具体步骤 下面对该项目的步骤进行一些具体的讲解以及部分关键代码的呈现。 一、扫一扫获取图书ISBN码 二、准备环境、安装依赖 1.安装Node.js准备环境 2.在cmd打开云函数目录中,安装依赖 三、编写云函数代码 1.在云函数中用获取到的ISBN传参 2.编写用户端(小程序端代码) 3.编写云函数端代码 四、调用豆瓣API获取具体数据 五、将获取到的API数据存入云数据库里面 1.初始化 2.添加数据 六、云数据库读取的数据显示在小程序端列表里 1.获取res.data 2.设置界面相关数据 3.显示和布局 4.小程序wxml界面(主要demo) 七、【云开发】首页列表跳转详情页 1.新建一个详情页 2.按钮跳转事件 3.跳转到具体详情页 4.关于详情页的一些代码 一、扫一扫获取图书ISBN码 用户端小程序调用 wx.scanCode接口,获取到图书ISBN码(图书条形码),在办公室找了一圈,找到了一本图书ISBN码,可以自动忽略我这渣渣的像素。 [图片] 关键代码 [代码]// pages/scanCode/scanCode.js Page({ /** * 页面的初始数据 */ data: { }, /** * 生命周期函数--监听页面加载 */ onLoad: function (options) { }, /** * 生命周期函数--监听页面初次渲染完成 */ onReady: function () { }, /** * 生命周期函数--监听页面显示 */ onShow: function () { }, /** * 生命周期函数--监听页面隐藏 */ onHide: function () { }, /** * 生命周期函数--监听页面卸载 */ onUnload: function () { }, /** * 页面相关事件处理函数--监听用户下拉动作 */ onPullDownRefresh: function () { }, /** * 页面上拉触底事件的处理函数 */ onReachBottom: function () { }, /** * 用户点击右上角分享 */ onShareAppMessage: function () { }, scanCode: function (event) { console.log(1) // 允许从相机和相册扫码 wx.scanCode({ onlyFromCamera:true, scanType:['barCode'], success:res=>{ console.log(res.result) }, fail:err=>{ console.log(err); } }) } }) [代码] ok,获取到信息: [图片] 二、准备环境、安装依赖 1.安装Node.js准备环境 安装nodejs,准备好环境,这一步就不细说了,没有安装的可以自行百度,不知道有没有安装的可以输入 node -v 查看一下。 2.在cmd打开云函数目录中,安装依赖 输入命令: [代码]npm install --production [代码] 依赖安装成功之后,文件里面多会出现 [代码]package-lock.json[代码]这个文件。 三、编写云函数代码 1.在云函数中用获取到的ISBN传参 云函数API: https://developers.weixin.qq.com/miniprogram/dev/wxcloud/reference-client-api/functions/callFunction.html 通过看文档可以学会,在云函数里,我们可以通过传递一份data来获取这里面的数据,然后再通过event来拿到对应的数据。 复制这个api里面的方法: [图片] 打开实战一里面写的小程序端的扫码的js界面,把这个方法放在 [代码]success[代码]里面。 要调用的云函数的名称 [代码]name[代码]要改成成实战二教程里面建立的云函数[代码]bookinfo[代码]: [图片] 传递的参数是 [代码]isbn[代码],结果是扫码得到的[代码]result[代码]: [图片] 2.编写用户端(小程序端代码) 将 [代码]result[代码]的结果打印出来,ok,用户端(小程序端)代码写好了。 用户端(小程序端)代码写完了,就这些: [代码]// pages/scanCode/scanCode.js Page({ /** * 页面的初始数据 */ data: { }, scanCode: function(event) { console.log(1) // 允许从相机和相册扫码 wx.scanCode({ onlyFromCamera: true, scanType: ['barCode'], success: res => { console.log(res.result) // wx.cloud.callFunction({ // 要调用的云函数名称 name: 'bookinfo', // 传递给云函数的参数 data: { isbn: res.result }, success: res => { console.log(res) }, fail: err => { console.error(res) } }) }, fail: err => { console.log(err); } }) } }) [代码] 3.编写云函数端代码 打开 [代码]bookinfo[代码]里面的 [代码]index.js[代码],将 [代码]event[代码]结果打印出来,请求云函数,将云函数之中的 [代码]isbn[代码]返回回来。 [代码]// 云函数入口文件 // const cloud = require('wx-server-sdk') // cloud.init() // 云函数入口函数 //var rp = require('request-promise') exports.main = async (event, context) => { console.logI(event); return event.isbn // var res = rp('https://api.douban.com/v2/book/isbn/' + event.isbn).then(html => { // return html; // }).catch(err => { // console.log(err) // }) //return res // const wxContext = cloud.getWXContext() // return { // event, // openid: wxContext.OPENID, // appid: wxContext.APPID, // unionid: wxContext.UNIONID, // } } [代码] 上传并且部署云函数。 测试一下,云函数调用成功,返回的结果(控制台打印)是isbn: [图片] 四、调用豆瓣API获取具体数据 在网上找了一下,找到了一个可以用的豆瓣API: https://api.douban.com/v2/book/isbn/:9787111128069 打开云函数文件夹,index.js里面编写代码,引用request promise: [代码]var rp = require('request-promise') [代码] 自定义的isbn,使用一个+号来连接,在传递一个catch来处理错误情况: [代码]var res = rp( 'https://api.douban.com/v2/book/isbn/'+event.isbn).then(html=>{ return html;}).catch(err=>{ console.log(err)}) [代码] [代码]returnres[代码]res就是对应的html,将html传给用户端后,上传云函数。 继续测试一下,拿到这个条形码的信息了(书本的信息): [图片] [图片] 对于这些信息,进一步处理,拿到自己想要的信息。 打开小程序端scanCode.js: [代码] //进一步的处理方法 var bookString=res.result; console.log(JSON.parse(bookString)) [代码] [图片] 看到了整本图书上面的所有信息,修改这些信息,存入云数据库之中即可。 五、将获取到的API数据存入云数据库里面 1.初始化 使用数据库的时候,首先要进行初始化: 云开发数据库文档: https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/database/init.html 打开云开发控制台创建一个集合books。 打开小程序端js,初始化数据库: [代码] //云数据库初始化 const db = wx.cloud.database({}); const book = db.collection('books'); [代码] 2.添加数据 js代码流程: [代码]// pages/scanCode/scanCode.js Page({ data: { }, scanCode: function (event) { console.log(1) // 允许从相机和相册扫码 wx.scanCode({ onlyFromCamera: true, scanType: 'barCode', success: res => { console.log(res.result) wx.cloud.callFunction({ // 要调用的云函数名称 name: 'bookinfo', // 传递给云函数的参数 data: { isbn: res.result }, success: res => { // console.log(res) //进一步的处理 var bookString = res.result; console.log(JSON.parse(bookString)) //云数据库初始化 const db = wx.cloud.database({}); const book = db.collection('books') db.collection('books').add({ // data 字段表示需新增的 JSON 数据 data: JSON.parse(bookString) }).then(res => { console.log(res) }).catch(err => { console.log(err) }) }, fail: err => { console.error(res) } }) }, fail: err => { console.log(err); } }) } }) [代码] 六、云数据库读取的数据显示在小程序端列表里 1.获取res.data 参考的读取api,请点击: https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/database/read.html 初始化实例和book方法: [代码] //云数据库初始化 const db = wx.cloud.database({}); const book = db.collection('books') [代码] 复制API这段代码获取多个记录的数据的方法,放在项目到onload方法之中。 打印在控制台: [图片] 2.设置界面相关数据 拿到res.data之后,要赋值给page实例里面的data,所以在data里面设置一个默认的空数组: [图片] 创建一个变量来保存页面page示例中的this,方便后续使用,也可以使用箭头函数来打印一下this,看是不是page示例: [代码]const db = wx.cloud.database({}); const cont = db.collection('books'); Page({ data: { book\_list:[] }, onLoad: function(options) { // 创建一个变量来保存页面page示例中的this, 方便后续使用 var _this=this; db.collection('books').get({ success: res =>{ console.log(res.data); console.log(this); } }) }, }) [代码] [图片] 直接使用this来设置data: [图片] 3.显示和布局 使用组件库引入,可以省略自己写很多代码的样式,简单方便,当然也可以自己写:https://youzan.github.io/vant-weapp/#/card 因为数据不止一条,循环,所以要用到小程序框架的列表渲染: https://developers.weixin.qq.com/miniprogram/dev/framework/view/wxml/list.html 写好之后 wxml如下: [代码]<text>私家书柜</text> <view wx:for="{{book\\_list}}"> <van-card num="2" price="2.00" desc="描述信息" title="商品标题" /> </view> [代码] 4.小程序wxml界面(主要demo) wxml: [代码]<view wx:for="{{book\\_list}}"> <van-card num="2" price="{{item.price}}" desc="{{item.author}}" title="{{item.title}}" thumb="{{item.image }}" /> </view> [代码] js: [代码]const db = wx.cloud.database({}); const cont = db.collection('books'); Page({ data: { book_list:[] }, onLoad: function(options) { // 创建一个变量来保存页面page示例中的this, 方便后续使用 var _this=this; db.collection('books').get({ success: res =>{ console.log(res.data[0]); this.setData({ book_list:res.data }) } }) }, }) [代码] ok,云数据库读取的数据显示在小程序端列表里。 七、【云开发】首页列表跳转详情页 1.新建一个详情页 打开app.json, [代码]"pages/details/details",[代码],自动生成了一个详情页: [图片] 2.按钮跳转事件 打开首页列表页代码,绑定详情按钮跳转事件。 wxml: [代码]<view wx:for="{{book\\_list}}"> <van-card num="2" price="{{item.price}}" desc="{{item.author}}" title="{{item.title}}" thumb="{{item.image }}"> <view slot="footer"> <van-button size="mini" bind:click="viewitem">详情按钮</van-button> </view> </van-card> </view> [代码] [图片] 继续写js里面的绑定事件,在控制台打印一下event,方便后续测试: [代码]viewitem: function(event) { console.log(event) } [代码] 3.跳转到具体详情页 要在云开发里面写一个特定的id,打开云开发控制台,数据库,需要用到这个下划线是_id的字段: [图片] 给这个字段设置一个值,[代码]data-id="{{item._id}}"[代码]: [图片] 点击按钮,可以看到,点击不同的列表,打印的是不同的id,通过不同的id就可以看到不同的内容了: [图片] 4.关于详情页的一些代码 初始化db的实例: [代码]const db = wx.cloud.database({}); [代码] 打开云函数文档里面的读取数据api: https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/database/read.html 复制此段读取数据记录的代码,放在onload里面: [图片] 可以看到,具体数据已经打印过来了: [图片] 这个时候还没有将数据传递到一个具体的页面实例中: [图片] 所以,success开始改成使用箭头函数,进入页面的时候,可以看到appdata里面的book: [图片] 具体展示:在wxml里面写上想要拿到的数据,ok,详情页面展示的数据: [图片] 效果如下: [图片] 这样,我们就完成了利用云开发扫码读取ISBN码并获取图书各种信息的全部步骤啦~
2019-04-17 - 如何监听小程序中的手势事件(缩放、双击、长按、滑动、拖拽)
mina-touch [图片] [代码]mina-touch[代码],一个方便、轻量的 小程序 手势事件监听库 事件库部分逻辑参考[代码]alloyFinger[代码],在此做出声明和感谢 change log: 2019.03.10 优化监听和绘制逻辑,动画不卡顿 2019.03.12 修复第二次之后缩放闪烁的 bug,pinch 添加 singleZoom 参数 2020.12.13 更名 mina-touch 2020.12.27 上传 npm 库;优化使用方式;优化 README 支持的事件 支持 pinch 缩放 支持 rotate 旋转 支持 pressMove 拖拽 支持 doubleTap 双击 支持 swipe 滑动 支持 longTap 长按 支持 tap 按 支持 singleTap 单击 扫码体验 [图片] demo 展示 demo1:监听 pressMove 拖拽 手势 查看 demo 代码 [图片] [图片] demo2: 监听 pinch 缩放 和 rotate 旋转 手势 (已优化动画卡顿 bug) 查看 demo 代码 [图片] [图片] demo3: 测试监听双击事件 查看 demo 代码 [图片] [图片] demo4: 测试监听长按事件 查看 demo 代码 [图片] [图片] demo 代码 demo 代码地址 mina-tools-client/mina-touch 使用方法 大致可以分为 4 步: npm 安装 mina-touch,开发工具构建 npm 引入 mina-touch onload 实例化 mina-touch wxml 绑定实例 命令行 [代码]npm install mina-touch[代码] 安装完成后,开发工具构建 npm *.js [代码]import MinaTouch from 'mina-touch'; // 1. 引入mina-touch Page({ onLoad: function (options) { // 2. onload实例化mina-touch //会创建this.touch1指向实例对象 new MinaTouch(this, 'touch1', { // 监听事件的回调:multipointStart,doubleTap,longTap,pinch,pressMove,swipe等等 // 具体使用和参数请查看github-README(底部有github地址 }); }, }); [代码] NOTE: 多类型事件监听触发 setData 时,建议把数据合并,在 touchMove 中一起进行 setData ,以减少短时内多次 setData 引起的动画延迟和卡顿(参考 demo2) *.wxml 在 view 上绑定事件并对应: [代码]<view catchtouchstart="touch1.start" catchtouchmove="touch1.move" catchtouchend="touch1.end" catchtouchcancel="touch1.cancel" > </view> <!-- touchstart -> 实例对象名.start touchmove -> 实例对象名.move touchend -> 实例对象名.end touchcancel -> 实例对象名.cancel --> [代码] NOTE: 如果不影响业务,建议使用 catch 捕获事件,否则易造成监听动画卡顿(参考 demo2) 以上简单几步即可使用 mina-touch 手势库 😊😊😊 具体使用和参数请查看Github https://github.com/Yrobot/mina-touch 如果喜欢mina-touch的话,记得在github点个start哦!🌟🌟🌟
2021-06-24 - 你不知道的Virtual DOM(一):Virtual Dom介绍
一、前言 目前最流行的两大前端框架,React 和 Vue,都不约而同的借助 Virtual DOM 技术提高页面的渲染效率。那么,什么是 Virtual DOM?它是通过什么方式去提升页面渲染效率的呢?本系列文章会详细讲解 Virtual DOM 的创建过程,并实现一个简单的 Diff 算法来更新页面。本文的内容脱离于任何的前端框架,只讲最纯粹的 Virtual DOM。敲单词太累了,下文 Virtual DOM 一律用 VD 表示。 这是 VD 系列文章的开篇,后续还会有更多的文章带你深入了解 VD 的奥秘。 二、VD 是什么 本质上来说,VD 只是一个简单的 JS 对象,并且最少包含 tag、 props和 children三个属性。不同的框架对这三个属性的命名会有点差别,但表达的意思是一致的。它们分别是标签名(tag)、属性(props)和子元素对象(children)。下面是一个典型的 VD 对象例子: [代码]{ tag: "div", props: {}, children: [ "Hello World", { tag: "ul", props: {}, children: [{ tag: "li", props: { id: 1, class: "li-1" }, children: ["第", 1] }] } ] } [代码] VD 跟 dom 对象有一一对应的关系,上面的 VD 是由以下的 HTML 生成的: [代码]<div> Hello World <ul> <li id="1" class="li-1"> 第1 </li> </ul> </div> [代码] 一个 dom 对象,比如 li,由 tag(li), props({id:1,class:“li-1”})和 children([“第”,1])三个属性来描述。 三、为什么需要 VD 借助 VD,可以达到有效减少页面渲染次数的目的,从而提高渲染效率。我们先来看下页面的更新一般会经过几个阶段: [图片] 从上面的例子中,可以看出页面的呈现会分以下 3 个阶段: JS 计算 生成渲染树 绘制页面 这个例子里面,JS 计算用了 691毫秒,生成渲染树 578毫秒,绘制 73毫秒。如果能有效的减少生成渲染树和绘制所花的时间,更新页面的效率也会随之提高。 通过 VD 的比较,我们可以将多个操作合并成一个批量的操作,从而减少 dom 重排的次数,进而缩短了生成渲染树和绘制所花的时间。至于如何基于 VD 更有效率的更新 dom,是一个很有趣的话题,日后有机会将另写一篇文章介绍。 四、如何实现 VD 与真实 DOM 的映射 我们先从如何生成 VD 说起。借助 JSX 编译器,可以将文件中的 HTML 转化成函数的形式,然后再利用这个函数生成 VD。看下面这个例子: [代码]function render() { return ( <div> Hello World <ul> <li id="1" class="li-1"> 第1 </li> </ul> </div> ); } [代码] 这个函数经过 JSX 编译后,会输出下面的内容: [代码]function render() { return h( 'div', null, 'Hello World', h( 'ul', null, h( 'li', { id: '1', 'class': 'li-1' }, '\u7B2C1' ) ) ); } [代码] 这里的 h 是一个函数,可以起任意的名字。这个名字通过 babel 进行配置: [代码]// .babelrc 文件 { "plugins": [ ["transform-react-jsx", { "pragma": "h" // 这里可配置任意的名称 }] ] } [代码] 接下来,我们只需要定义 h 函数,就能构造出 VD: [代码]function flatten(arr) { return [].concat.apply([], arr); } function h(tag, props, ...children) { return { tag, props: props || {}, children: flatten(children) || [] }; } [代码] h 函数会传入三个或以上的参数,前两个参数一个是标签名,一个是属性对象,从第三个参数开始的其它参数都是 children。children 元素有可能是数组的形式,需要将数组解构一层。比如: [代码]function render() { return ( <ul> <li>0</li> { [1, 2, 3].map( i => ( <li>{i}</li> )) } </ul> ); } // JSX 编译后 function render() { return h( 'ul', null, h( 'li', null, '0' ), /* * 需要将下面这个数组解构出来再放到 children 数组中 */ [1, 2, 3].map(i => h( 'li', null, i )) ); } [代码] 继续之前的例子。执行 h 函数后,最终会得到如下的 VD 对象: [代码]{ tag: "div", props: {}, children: [ "Hello World", { tag: "ul", props: {}, children: [{ tag: "li", props: { id: 1, class: "li-1" }, children: ["第", 1] }] } ] } [代码] 下一步,通过遍历 VD 对象,生成真实的 dom [代码]// 创建 dom 元素 function createElement(vdom) { // 如果 vdom 是字符串或者数字类型,则创建文本节点,比如“Hello World” if (typeof vdom === 'string' || typeof vdom === 'number') { return doc.createTextNode(vdom); } const {tag, props, children} = vdom; // 1. 创建元素 const element = doc.createElement(tag); // 2. 属性赋值 setProps(element, props); // 3. 创建子元素 // appendChild 在执行的时候,会检查当前的 this 是不是 dom 对象,因此要 bind 一下 children.map(createElement) .forEach(element.appendChild.bind(element)); return element; } // 属性赋值 function setProps(element, props) { for (let key in props) { element.setAttribute(key, props[key]); } } [代码] createElement函数执行完后,dom元素就创建完并展示到页面上了(页面比较丑,不要介意…)。 [图片] 五、总结 本文介绍了 VD 的基本概念,并讲解了如何利用 JSX 编译 HTML 标签,然后生成 VD,进而创建真实 dom 的过程。下一篇文章将会实现一个简单的 VD Diff 算法,找出 2 个 VD 的差异并将更新的元素映射到 dom 中去。 PS: 想看完整代码见这里: 代码(https://gist.github.com/dickenslian/86c4e266ae5f2134373376133bec9e3d) 参考链接: The Inner Workings Of Virtual DOM (https://medium.com/@rajaraodv/the-inner-workings-of-virtual-dom-666ee7ad47cf) preact源码学习系列之一:JSX解析与DOM渲染 (https://github.com/youngwind/blog/issues/103)
2019-03-04 - 【微信小程序】性能优化
为什么要做性能优化? 一切性能优化都是为了体验优化 1. 使用小程序时,是否会经常遇到如下问题? 打开是一直白屏 打开是loading态,转好几圈 我的页面点了怎么跳转这么慢? 我的列表怎么越滑越卡? 2. 我们优化的方向有哪些? 启动加载性能 渲染性能 3. 启动加载性能 1. 首次加载 你是否见过小程序首次加载时是这样的图? [图片] 这张图中的三种状态对应的都是什么呢? 小程序启动时,微信会为小程序展示一个固定的启动界面,界面内包含小程序的图标、名称和加载提示图标。此时,微信会在背后完成几项工作:[代码]下载小程序代码包[代码]、[代码]加载小程序代码包[代码]、[代码]初始化小程序首页[代码]。下载到的小程序代码包不是小程序的源代码,而是编译、压缩、打包之后的代码包。 2. 加载顺序 小程序加载的顺序是如何? 微信会在小程序启动前为小程序准备好通用的运行环境。这个运行环境包括几个供小程序使用的线程,并在其中完成小程序基础库的初始化,预先执行通用逻辑,尽可能做好小程序的启动准备。这样可以显著减少小程序的启动时间。 [图片] 通过2,我们知道了,问题1中第一张图是[代码]资源准备[代码](代码包下载);第二张图是[代码]业务代码的注入以及落地页首次渲染[代码];第三张图是[代码]落地页数据请求时的loading态[代码](部分小程序存在) 3. 控制包大小 提升体验最直接的方法是控制小程序包的大小,这是最显而易见的 勾选开发者工具中“上传代码时,压缩代码”选项; 及时清理无用的代码和资源文件(包括无用的日志代码) 减少资源包中的图片等资源的数量和大小(理论上除了小icon,其他图片资源从网络下载),图片资源压缩率有限 从开发者的角度看,控制代码包大小有助于减少小程序的启动时间。对低于1MB的代码包,其下载时间可以控制在929ms(iOS)、1500ms(Android)内。 4. 采用分包加载机制 根据业务场景,将用户访问率高的页面放在主包里,将访问率低的页面放入子包里,按需加载; [图片] 使用分包时需要注意代码和资源文件目录的划分。启动时需要访问的页面及其依赖的资源文件应放在主包中。 5 采用分包预加载技术 在4的基础上,当用户点击到子包的目录时,还是有一个代码包下载的过程,这会感觉到明显的卡顿,所以子包也不建议拆的太大,当然我们可以采用子包预加载技术,并不需要等到用户点击到子包页面后在下载子包,而是可以根据后期数据,做子包预加载,将用户在当先页可能点击的子包页面先加载,当用户点击后直接跳转; [图片] 这种基于配置的子包预加载技术,是可以根据用户网络类型来判断的,当用户处于网络条件好时才预加载;是灵活可控的 6. 采用独立分包技术 目前很多小程序[代码]主包+子包[代码](2M+6M)的方式,但是在做很多运营活动时,我们会发现活动(红包)是在子包里,但是运营、产品投放的落地页链接是子包链接,这是的用户在直达落地时,必须先下载主包内容(一般比较大),在下载子包内容(相对主包,较小),这使得在用户停留时间比较短的小程序场景中,用户体验不是很好,而且浪费了很大部分流量; [图片] 可以采用独立分包技术,区别于子包,和主包之间是无关的,在功能比较独立的子包里,使用户只需下载分包资源; 7. 首屏加载的优化建议 7.1 提前请求 异步请求可以在页面onLoad就加载,不需要等页面ready后在异步请求数据;当然,如果能在前置页面点击跳转时预请求当前页的核心异步请求,效果会更好; 7.2 利用缓存 利用storage API, 对变动频率比较低的异步数据进行缓存,二次启动时,先利用缓存数据进行初始化渲染,然后后台进行异步数据的更新,这不仅优化了性能,在无网环境下,用户也能很顺畅的使用到关键服务; 7.3 避免白屏 可以在前置页面将一些有用的字段带到当前页,进行首次渲染(列表页的某些数据–> 详情页),没有数据的模块可以进行骨架屏的占位,使用户不会等待的很焦虑,甚至走了; 7.4 及时反馈 及时的对需要用户等待的交互操作进行反馈,避免用户以为小程序卡了,无响应 渲染性能优化 1. 小程序渲染原理 双线程下的界面渲染,小程序的逻辑层和渲染层是分开的两个线程。在渲染层,宿主环境会把WXML转化成对应的JS对象,在逻辑层发生数据变更的时候,我们需要通过宿主环境提供的setData方法把数据从逻辑层传递到渲染层,再经过对比前后差异,把差异应用在原来的Dom树上,渲染出正确的UI界面。 [图片] 分析这个流程不难得知:页面初始化的时间大致由页面初始数据通信时间和初始渲染时间两部分构成。其中,数据通信的时间指数据从逻辑层开始组织数据到视图层完全接收完毕的时间,数据量小于64KB时总时长可以控制在30ms内。传输时间与数据量大体上呈现正相关关系,传输过大的数据将使这一时间显著增加。因而减少传输数据量是降低数据传输时间的有效方式。 [图片] 2. 避免使用不当setData 在数据传输时,逻辑层会执行一次[代码]JSON.stringify[代码]来去除掉[代码]setData[代码]数据中不可传输的部分,之后将数据发送给视图层。同时,逻辑层还会将[代码]setData[代码]所设置的数据字段与[代码]data[代码]合并,使开发者可以用[代码]this.data[代码]读取到变更后的数据。因此,为了提升数据更新的性能,开发者在执行[代码]setData[代码]调用时,最好遵循以下原则: 2.1 不要过于频繁调用setData,应考虑将多次setData合并成一次setData调用; [图片] 2.2 数据通信的性能与数据量正相关,因而如果有一些数据字段不在界面中展示且数据结构比较复杂或包含长字符串,则不应使用[代码]setData[代码]来设置这些数据; [图片] 2.3 与界面渲染无关的数据最好不要设置在data中,可以考虑设置在page对象的其他字段下 [图片] 提升数据更新性能方式的代码示例 [代码]Page({ onShow: function() { // 不要频繁调用setData this.setData({ a: 1 }) this.setData({ b: 2 }) // 绝大多数时候可优化为 this.setData({ a: 1, b: 2 }) // 不要设置不在界面渲染时使用的数据,并将界面无关的数据放在data外 this.setData({ myData: { a: '这个字符串在WXML中用到了', b: '这个字符串未在WXML中用到,而且它很长…………………………' } }) // 可以优化为 this.setData({ 'myData.a': '这个字符串在WXML中用到了' }) this._myData = { b: '这个字符串未在WXML中用到,而且它很长…………………………' } } }) [代码] 利用setData进行列表局部刷新 在一个列表中,有[代码]n[代码]条数据,采用上拉加载更多的方式,假如这个时候想对其中某一个数据进行点赞操作,还能及时看到点赞的效果 解决方法 1、可以采用setData全局刷新,点赞完成之后,重新获取数据,再次进行全局重新渲染,这样做的优点是:方便,快捷!缺点是:用户体验极其不好,当用户刷量100多条数据后,重新渲染量大会出现空白期(没有渲染过来) 2、说到重点了,就是利用[代码]setData[代码]局部刷新 [代码]> a.将点赞的`id`传过去,知道点的是那一条数据, 将点赞的`id`传过去,知道点的是那一条数据 [代码] [代码]<view wx:if="{{!item.status}}" class="btn" data-id="{{index}}" bindtap="couponTap">立即领取</view> [代码] [代码]> b.重新获取数据,查找相对应id的那条数据的下标(`index`是不会改变的) > c.用setData进行局部刷新 [代码] [代码]this.setData({ list[index] = newList[index] }) [代码] 其实这个小操作对刚刚接触到微信小程序的人来说应该是不容易发现的,不理解setData还有这样的写法。 2.4 切勿在后台页面进行setData 在一些页面会进行一些操作,而到页面跳转后,代码逻辑还在执行,此时多个[代码]webview[代码]是共享一个js进程;后台的[代码]setData[代码]操作会抢占前台页面的渲染资源; [图片] [图片] 3. 用户事件使用不当 视图层将事件反馈给逻辑层时,同样需要一个通信过程,通信的方向是从视图层到逻辑层。因为这个通信过程是异步的,会产生一定的延迟,延迟时间同样与传输的数据量正相关,数据量小于64KB时在30ms内。降低延迟时间的方法主要有两个。 1.去掉不必要的事件绑定(WXML中的[代码]bind[代码]和[代码]catch[代码]),从而减少通信的数据量和次数; 2.事件绑定时需要传输[代码]target[代码]和[代码]currentTarget[代码]的[代码]dataset[代码],因而不要在节点的[代码]data[代码]前缀属性中放置过大的数据。 [图片] 4. 视图层渲染原理 4.1首次渲染 初始渲染发生在页面刚刚创建时。初始渲染时,将初始数据套用在对应的WXML片段上生成节点树。节点树也就是在开发者工具WXML面板中看到的页面树结构,它包含页面内所有组件节点的名称、属性值和事件回调函数等信息。最后根据节点树包含的各个节点,在界面上依次创建出各个组件。 [图片] 在这整个流程中,时间开销大体上与节点树中节点的总量成正比例关系。因而减少WXML中节点的数量可以有效降低初始渲染和重渲染的时间开销,提升渲染性能。 简化WXML代码的例子 [代码]<view data-my-data="{{myData}}"> <!-- 这个 view 和下一行的 view 可以合并 --> <view class="my-class" data-my-data="{{myData}}" bindtap="onTap"> <text> <!-- 这个 text 通常是没必要的 --> {{myText}} </text> </view> </view> <!-- 可以简化为 --> <view class="my-class" data-my-data="{{myData}}" bindtap="onTap"> {{myText}} </view> [代码] 4.2 重渲染 初始渲染完毕后,视图层可以多次应用[代码]setData[代码]的数据。每次应用[代码]setData[代码]数据时,都会执行重渲染来更新界面。初始渲染中得到的data和当前节点树会保留下来用于重渲染。每次重渲染时,将[代码]data[代码]和[代码]setData[代码]数据套用在WXML片段上,得到一个新节点树。然后将新节点树与当前节点树进行比较,这样可以得到哪些节点的哪些属性需要更新、哪些节点需要添加或移除。最后,将[代码]setData[代码]数据合并到[代码]data[代码]中,并用新节点树替换旧节点树,用于下一次重渲染。 [图片] 在进行当前节点树与新节点树的比较时,会着重比较[代码]setData[代码]数据影响到的节点属性。因而,去掉不必要设置的数据、减少[代码]setData[代码]的数据量也有助于提升这一个步骤的性能。 5. 使用自定义组件 自定义组件的更新只在组件内部进行,不受页面其他不能分内容的影响;比如一些运营活动的定时模块可以单独抽出来,做成一个定时组件,定时组件的更新并不会影响页面上其他元素的更新;各个组件也将具有各自独立的逻辑空间。每个组件都分别拥有自己的独立的数据、setData调用。 [图片] 6. 避免不当的使用onPageScroll 每一次事件监听都是一次视图到逻辑的通信过程,所以只在必要的时候监听pageSrcoll [图片] 总结 小程序启动加载性能 控制代码包的大小 分包加载 首屏体验(预请求,利用缓存,避免白屏,及时反馈 小程序渲染性能 避免不当的使用setData 合理利用事件通信 避免不当的使用onPageScroll 优化视图节点 使用自定义组件
2019-03-07 - 聊一聊小程序开发中的单位如何布局使用?
小程序支持的单位? 可以说小程序就是在微信体系网页的另一种表现方式。网页中的单位小程序基本都支持。但实际开发中,我常用到的是以下几种 ↓ rpx rpx做为小程序自家系统里的单位,特性是可以根据屏幕宽度进行自适应。rpx官方介绍 比如我写一个2:1比例的全屏轮播图,可以这样写: [代码]swiper { width:750rpx; height:375rpx; } [代码] 1rpx = 0.5px = 1物理像素。网页开发中,默认字体一般设置为14px,在小程序中我们就可以设置小程序的默认字体大小为28rpx。 px 在小程序开发中 rpx基本就代替了px,但在一些特殊的场合,px的表现要比rpx好。 兼容ipad时,由于ipad可以横屏和竖屏,并且屏幕宽度可以达到2K以上,如果你的小程序要考虑到兼容ipad,那么还是多考虑使用px吧。 覆盖微信原生组件样式。em????可以覆盖微信原生样式??? 是的,只有小程序老玩家才知道的秘密!小程序原生样式是可以覆盖美化的,以 <switch> 组件为例:switch代码片段 [图片] 导入代码片段到开发者工具中,并切换设备模式预览可以发现rpx表现不佳。使用px反而更好。 em与rem em与rem在H5的网页开发上可以大放异彩,但小程序中因为有rpx的存在,em与rem使用的就少了。基本只有在一些对字体宽度有特效的情况下才会使用。比如首行缩进。 vw、vh和百分比 vw:视窗宽度,1vw等于视窗宽度的1%。 vh:视窗高度,1vh等于视窗高度的1%。 %:父级容器的宽度百分百。 [图片] calc() 的使用 前面讲了单位,那么我们现在来聊聊怎么使用这些单位了。小程序是网页的一种,支持css,也支持calc()。 这里吃下书: calc() 函数用于动态计算长度值。 [代码] ● 需要注意的是,运算符前后都需要保留一个空格,例如:width: calc(100% - 10px); ● 任何长度值都可以使用calc()函数进行计算; ● calc()函数支持 "+", "-", "*", "/" 运算; ● calc()函数使用标准的数学运算优先级规则; [代码] 使用场景示例 垂直导航页,常用于外卖订餐或者商城的二级分类页。 上半部分是定死高度375rpx的轮播图区域,下半部分是可以随设备高度变化的可滚动的区域。容器高度可以这样写: [代码]{ height:calc(100vh - 375rpx) } [代码] [图片] 结尾 夜深了,晚安,不定期更新小程序使用技巧。新人写文章,大佬多指点! [图片]
2019-02-26 - WordPress做一个简约有气质的微信小程序开源分享给大家
小程序官网: https://www.weitimes.com 小程序地址 https://www.wpstorm.cn 联系微信: hackdxd WordPress多端开源小程序,这是丸子团队在发布丸子社区小程序之后,首次打造一款免费简洁的开源小程序。 要维持丸子社区小程序的升级,又要开发一款支持微信和 QQ的小程序前端,真的挺不容易的。 但是,我们还是坚持了下来,希望能够帮助到想要基于 WordPress 程序创建小程序的用户。 这次的开源小程序功能不多,界面清新,简洁。 开源小程序功能: 文章资源实时同步 支持视频在线播放 支持海报生成 支持分享朋友圈 支持搜索关键词文章 支持文章点赞 / 收藏 / 评论 支持查看个人点赞 / 收藏 / 评论文章列 没有太复杂的功能,除了简洁还是简洁,除了清新还是清新,重要的是,如此优雅的小程序,加载性能好,适用性强。 小程序演示截图: [图片] [图片] 我们已经把开源小程序发布到了WordPress小程序主题资源平台上,如果你对小程序有兴趣。 可以访问 WordPress小程序主题资源平台下载: https://www.wpstorm.cn 我们已经把这款开源小程序发布到了开源平台上,如果你对这款小程序有兴趣,可以上以下两个平台下载: 地址; 如何下载开源小程序? 复制以上地址在你的浏览器打开,选择下载即可,了解详情( 记得给我们的开源小程序 Star 哦 _)。 GitHub地址:https://github.com/dchijack/Travel-Mini-Program GiTee地址:https://gitee.com/izol/Travel-Mini-Program 丸子WodPress小程序付费版本(丸子社区/丸子资讯) 强大的后台功能以及简化的小程序设置,几乎不需要更改小程序源码,只要在网站后台简单的配置信息,就能拥有一个漂亮又功能强大的社区小程序。 在线体验扫码: wseywt.jpg 基本功能 网站小程序数据实时同步更新; 首页推荐文章展示; 文章/动态/点赞/评论/收藏/转发; 文章海报生成; 评论跟随; 强大的广告功能; 支持文章内容转换语音朗诵, 采用百度语音/讯飞语音技术,支持用户自行选择平台; 社区功能 小程序端发表社区话题,发布文章; 小程序文章链接分享,可自动获取分享链接的内容,支持的分享链接包括:微信公众号、抖音、微博、B站文章内容; 小程序视频链接分享,可自动获取分享视频地址,支持的分享视频包括:微博视频、抖音、VueVideo及特定的腾讯视频链接; 支持用户关注,可以互相关注,随时可以了解你喜欢的人发布的内容; 积分功能:支持阅读、评论、发布及签到积分,可以设定每天最高可增加积分额度; 新增功能 微信小程序扫码登录网站,专属小程序在线扫码 基于位置信息的动态发布及文章推荐,可以让你快速找到附近的人。 丸子社区新版做了整体UI界面优化 圈子 新增了仿微信视频号功能 新增了发文章功能 新增了解析接口调整 新增了支持拼多多带货一键发布 小程序演示截图: wsVr1H.png wsVfN8.png wsVI3Q.png 一款UI设计精美的自媒体小程序目前支持四端两款模板,完全后台一键式配置,直接使用,基本不懂编程的小白都可以使用。 在线体验扫码NO1: wsmp01.jpg 在线体验扫码NO2: wsmn0I.jpg 基础功能 增加自定义文章类型-公众号推文 支持创建焦点幻灯片图文显示 支持腾讯视频 / 微博视频解析 支持积分功能,签到打卡积分 支持多端积分付费阅读文章 支持 QQ 小程序消息模板通知 支持百度小程序消息模板通知 支持微信小程序关联公众号文章 支持网站后台下载小程序 小程序演示模板NO1: wsVTjs.png wsVbBq.png 小程序演示模板NO2: wsVz34.png wsZpv9.png wsZPD1.png
2021-04-03 - 商城类小程序开发全过程(附源码)
商城类小程序是所有类别小程序中开发的热点,但其相对于其他类别的小程序,具有逻辑复杂、功能需求较大等特点,因此,对开发者具有一定的基础要求。 那我们就止步于前了吗? 当然不! 本文主要讲述商城小程序开发的过程,并附上了源码,希望对各个开发者能有所帮助。 #一、明确项目需求 在开发小程序之前,需要明确,商城类小程序有哪些基本功能和需求。 需求如下: [图片] #二、账号注册 后端开发难度太大,可以直接使用支持小程序开发的后端平台Bmob后端云www.bmob.cn,注册账号。 #三、目录结构设计 [图片] #四、设计与开发 详细的代码不做过多解释,请直接查看源码。 强调一下后端平台使用的过程: [图片] #五、效果图 [图片] [图片] #六、源码下载 源码下载链接 (markdown添加不了附件,只能去这个地址下载源码了,下载是免费的) 如有疑问,可加QQ群:586487943 个人QQ:624235922 源码可以发给你 大家多交流,哈哈
2019-02-27