- 原生小程序自定义导航栏
微信原生小程序导航栏 navigation-bar 使用说明 [图片][图片] 1、安装插件,并在小程序开发工具中构建 npm [代码]npm i navigationbar-wx [代码] 2、app.js 中得 globalData 内写上如下: [代码]import { navigationbarWx } from "navigationbar-wx/config.js"; navigationbarWx.init({ title: "小程序默认名称", homePath: "/pages/index/index",//默认路径,如果相同可以忽略这个参数 }) //备注:init 可初始化的字段如下字段说明 [代码] 3、app.json 中配置插件 [代码]"usingComponents": { "navigationbar-wx": "navigationbar-wx" } [代码] 4、页面使用-传参如下说明 [代码]<!-- 可直接使用 --> <navigationbar-wx /> <!-- 也可传参使用 --> <navigationbar-wx title="" logo="" color="" bgColor="" bgImg="" iconColor="" hideHome="" hideSeat="" /> [代码] 字段说明 字段 必传 类型 默认值 说明 title 否 String 小程序默认标题 当前页面标题 logo 否 String 无 可传入一个图片 URL 作为标题,logo 优先级大于 title color 否 String #000000 字体颜色,默认黑色 bgColor 否 String #ffffff 导航栏颜色,默认白色 bgImg 否 String 无 导航栏整体图片,背景图优先级大于背景颜色 iconColor 否 String black 左边按钮颜色,只支持 black、white 两种类型 hideHome 否 Boolean false 当没有上一级页面时,是否隐藏小房子 hideSeat 否 Boolean false 导航栏底部占位,默认占位不隐藏 homePath 否 String /pages/index/index 页面如果设置这个参数优先级高于 init 时传入的路径 github 地址 github 地址
06-17 - 从APIv3到APIv2再到企业微信,这款微信支付开发包的README你应该来读一读
The WeChatPay OpenAPI v2&v3’ Smart Development Kit [图片][图片][图片][图片][图片][图片] 主要功能 使用Node原生[代码]crypto[代码]实现微信支付APIv3的AES加/解密功能([代码]aes-256-gcm[代码] with [代码]aad[代码]) 使用Node原生[代码]crypto[代码]实现微信支付APIv3的RSA加/解密、签名、验签功能([代码]sha256WithRSAEncryption[代码] with [代码]RSA_PKCS1_OAEP_PADDING[代码]) 大部分微信支付APIv3的HTTP GET/POST/PUT/PATCH/DELETE应该能够正常工作,依赖 Axios, 示例代码如下 支持微信支付APIv3的媒体文件上传(图片/视频)功能,需手动安装 form-data, 示例代码如下 支持微信支付APIv3的应答证书下载功能,需手动安装 yargs, 使用手册如下 支持微信支付APIv3的帐单下载及解析功能,示例代码如下 支持微信支付APIv2 & APIv3面向对象编程模式,示例代码如下 支持 [代码]Typescript[代码] 支持微信支付XML风格的接口(通常所说v2)调用,依赖 node-xml2js, 示例代码如下 支持微信支付APIv2版的 [代码]AES-256-ECB/PKCS7PADDING[代码] 通知消息加/解密 APIv2 & APIv3 与微信交互的各种数据签名用法示例 支持 企业微信-企业支付-企业红包/向员工付款 功能,示例用法及代码如下 系统要求 NodeJS原生[代码]crypto[代码]模块,自v12.9.0在 [代码]publicEncrypt[代码] 及 [代码]privateDecrypt[代码] 增加了 [代码]oaepHash[代码] 入参选项,本类库封装的 [代码]Rsa.encrypt[代码] 及 [代码]Rsa.decrypt[代码] 显式声明了此入参,测试下来在NodeJS10.15.0上可正常工作;虽然在v10.15上可用,不过仍旧推荐使用 NodeJS >= v12.9.0。 安装 [代码]$ npm install wechatpay-axios-plugin[代码] 起步 v3平台证书 微信支付APIv3使用 (RESTful API with JSON over HTTP)接口设计,数据交换采用非对称([代码]RSA-OAEP[代码])加/解密方案。 API上行所需的[代码]商户API私钥[代码],可以由商户官方专用证书生成工具生成, API下行所需的[代码]平台证书[代码]须从[代码]v3/certificates[代码]接口获取(应答证书还经过了对称[代码]AES-GCM[代码]加密,须采用[代码]APIv3密钥[代码]才能解密)。 本项目也提供了命令行下载工具,使用手册如下: [代码]$ ./node_modules/.bin/wxpay crt --help[代码] [代码]wxpay crt The WeChatPay APIv3's Certificate Downloader cert -m, --mchid The merchant's ID, aka mchid. [string] [required] -s, --serialno The serial number of the merchant's certificate aka serialno. [string] [required] -f, --privatekey The path of the merchant's private key certificate aka privatekey. [string] [required] -k, --key The secret key string of the merchant's APIv3 aka key. [string] [required] -o, --output Path to output the downloaded WeChatPay's platform certificate(s) [string] [default: "/tmp"] Options: --version Show version number [boolean] --help Show help [boolean] -u, --baseURL The baseURL [string] [default: "https://api.mch.weixin.qq.com/"] [代码] 注: 像其他通用命令行工具一样,[代码]--help[代码] 均会打印出帮助手册,说明档里的[代码][required][代码]指 必选参数; [代码][string][代码]指 字符串类型,[代码][default][代码]指默认值 [代码]$ ./node_modules/.bin/wxpay crt</b> -m N -s S -f F.pem -k K -o .[代码] [代码]The WeChatPay Platform Certificate#0 serial=HEXADECIAL notBefore=Wed, 22 Apr 2020 01:43:19 GMT notAfter=Mon, 21 Apr 2025 01:43:19 GMT Saved to: wechatpay_HEXADECIAL.pem You may confirm the above infos again even if this library already did(by Rsa.verify): openssl x509 -in wechatpay_HEXADECIAL.pem -noout -serial -dates [代码] 注: 提供必选参数且运行后,屏幕即打印出如上信息,提示[代码]证书序列号[代码]及[代码]起、止格林威治(GMT)时间[代码]及证书下载保存位置。 命令行请求 v0.5版,命令行工具做了加强,增加了基础请求方法,可以用来做快速接入体验,用法如下: 帮助信息 [代码]$ ./node_modules/.bin/wxpay req --help[代码] [代码]wxpay req <uri> Play the WeChatPay OpenAPI requests over command line <uri> -c, --config The configuration [required] -b, --binary Point out the response as `arraybuffer` [boolean] -m, --method The request HTTP verb [choices: "DELETE", "GET", "POST", "PUT", "PATCH", "delete", "get", "post", "put", "patch"] [default: "POST"] -h, --headers Special request HTTP header(s) -d, --data The request HTTP body -p, --params The request HTTP query parameter(s) Options: --version Show version number [boolean] --help Show help [boolean] -u, --baseURL The baseURL [string] [default: "https://api.mch.weixin.qq.com/"] [代码] v3版Native付 [代码]./node_modules/.bin/wxpay v3.pay.transactions.native \ -c.mchid 1230000109 \ -c.serial HEXADECIAL \ -c.privateKey /path/your/merchant/mchid.key \ -c.certs.HEXADECIAL /path/the/platform/certificates/HEXADECIAL.pem \ -d.appid wxd678efh567hg6787 \ -d.mchid 1230000109 \ -d.description 'Image形象店-深圳腾大-QQ公仔' \ -d.out_trade_no '1217752501201407033233368018' \ -d.notify_url 'https://www.weixin.qq.com/wxpay/pay.php' \ -d.amount.total 100 \ -d.amount.currency CNY [代码] v2版付款码付 [代码]./node_modules/.bin/wxpay v2.pay.micropay \ -c.mchid 1230000109 \ -c.serial any \ -c.privateKey any \ -c.certs.any \ -c.secret your_merchant_secret_key_string \ -d.appid wxd678efh567hg6787 \ -d.mch_id 1230000109 \ -d.device_info 013467007045764 \ -d.nonce_str 5K8264ILTKCH16CQ2502SI8ZNMTM67VS \ -d.detail 'Image形象店-深圳腾大-QQ公仔' \ -d.spbill_create_ip 8.8.8.8 \ -d.out_trade_no '1217752501201407033233368018' \ -d.total_fee 100 \ -d.fee_type CNY \ -d.auth_code 120061098828009406 [代码] v2版付款码查询openid [代码]./node_modules/.bin/wxpay v2/tools/authcodetoopenid \ -c.mchid 1230000109 \ -c.serial any \ -c.privateKey any \ -c.certs.any \ -c.secret your_merchant_secret_key_string \ -d.appid wxd678efh567hg6787 \ -d.mch_id 1230000109 \ -d.nonce_str 5K8264ILTKCH16CQ2502SI8ZNMTM67VS \ -d.auth_code 120061098828009406 [代码] 面向对象模式 本类库自[代码]0.2[代码]开始,按照 [代码]URL.pathname[代码] 以[代码]/[代码]做切分,映射成对象属性,[代码]0.4[代码]版开始,支持APIv2的[代码]pathname[代码]映射,编码书写方式有如下约定: 请求 [代码]pathname[代码] 作为级联对象,可以轻松构建请求对象,例如 [代码]v3/pay/transactions/native[代码] 即自然翻译成 [代码]v3.pay.transactions.native[代码]; 每个 [代码]pathname[代码] 所支持的 [代码]HTTP METHOD[代码],即作为 请求对象的末尾执行方法,例如: [代码]v3.pay.transactions.native.post({})[代码]; 每个 [代码]pathname[代码] 级联对象默认为HTTP[代码]POST[代码]函数,其同时隐式内置[代码]GET/POST/PUT/PATCH/DELETE[代码] 操作方法链,支持全大写及全小写两种编码方式,说明见[代码]变更历史[代码]; 每个 [代码]pathname[代码] 有中线(dash)分隔符的,可以使用驼峰[代码]camelCase[代码]风格书写,例如: [代码]merchant-service[代码]可写成 [代码]merchantService[代码],或者属性风格,例如 [代码]v3['merchant-service'][代码]; 每个 [代码]pathname[代码] 中,若有动态参数,例如 [代码]business_code/{business_code}[代码] 可写成 [代码]business_code.$business_code$[代码] 或者属性风格书写,例如 [代码]business_code['{business_code}'][代码],抑或按属性风格,直接写值也可以,例如 [代码]business_code['2000001234567890'][代码]; SDK内置的 [代码]v2/[代码] 对象,其特殊标识为APIv2级联对象,之后串接切分后的[代码]pathname[代码],如源 [代码]pay/micropay[代码] 翻译成 [代码]v2.pay.micropay[代码] 即以XML形式请求远端接口; 建议 [代码]pathname[代码] 按照 [代码]PascalCase[代码] 风格书写, [代码]TS Definition[代码] 已在路上(还有若干问题没解决),将是这种风格,代码提示将会很自然; 以下示例用法,均以[代码]Promise[代码]或[代码]Async/Await[代码]结合此种编码模式展开,级联对象操作符的调试信息见文档末。 初始化 [代码]const {Wechatpay, Formatter} = require('wechatpay-axios-plugin') const wxpay = new Wechatpay({ // 商户号 mchid: 'your_merchant_id', // 商户证书序列号 serial: 'serial_number_of_your_merchant_public_cert', // 商户API私钥 PEM格式的文本字符串或者文件buffer privateKey: '-----BEGIN PRIVATE KEY-----\n-FULL-OF-THE-FILE-CONTENT-\n-----END PRIVATE KEY-----', certs: { // CLI `wxpay crt -m {商户号} -s {商户证书序列号} -f {商户API私钥文件路径} -k {APIv3密钥(32字节)} -o {保存地址}` 生成 'serial_number': '-----BEGIN CERTIFICATE-----\n-FULL-OF-THE-FILE-CONTENT-\n-----END CERTIFICATE-----', }, // APIv2密钥(32字节) v0.4 开始支持 secret: 'your_merchant_secret_key_string', // 接口不要求证书情形,例如仅收款merchant对象参数可选 merchant: { // 商户证书 PEM格式的文本字符串或者文件buffer cert: '-----BEGIN CERTIFICATE-----\n-FULL-OF-THE-FILE-CONTENT-\n-----END CERTIFICATE-----', // 商户API私钥 PEM格式的文本字符串或者文件buffer key: '-----BEGIN PRIVATE KEY-----\n-FULL-OF-THE-FILE-CONTENT-\n-----END PRIVATE KEY-----', // or // passphrase: 'your_merchant_id', // pfx: fs.readFileSync('/your/merchant/cert/apiclient_cert.p12'), }, // APIv2沙箱环境地址 // baseURL: 'https://api.mch.weixin.qq.com/sandboxnew/', // 建议初始化设置此参数,详细说明见Axios官方README // maxRedirects: 0, }) [代码] 初始化字典说明如下: [代码]mchid[代码] 为你的商户号,一般是10字节纯数字 [代码]serial[代码] 为你的商户证书序列号,一般是40字节字符串 [代码]privateKey[代码] 为你的商户API私钥,一般是通过官方证书生成工具生成的文件名是[代码]apiclient_key.pem[代码]文件,支持纯字符串或者文件流[代码]buffer[代码]格式 [代码]certs{[serial_number]:string}[代码] 为通过下载工具下载的平台证书[代码]key/value[代码]键值对,键为平台证书序列号,值为平台证书pem格式的纯字符串或者文件流[代码]buffer[代码]格式 [代码]secret[代码] 为APIv2版的[代码]密钥[代码],商户平台上设置的32字节字符串 [代码]merchant.cert[代码] 为你的商户证书,一般是文件名为[代码]apiclient_cert.pem[代码]文件,支持纯字符串或者文件流[代码]buffer[代码]格式 [代码]merchant.key[代码] 为你的商户API私钥,一般是通过官方证书生成工具生成的文件名是[代码]apiclient_key.pem[代码]文件,支持纯字符串或者文件流[代码]buffer[代码]格式 [代码]merchant.passphrase[代码] 一般为你的商户号 [代码]merchant.pfx[代码] 为你的商户[代码]PKCS12[代码]格式的证书,文件名一般为[代码]apiclient_cert.p12[代码],支持二进制文件流[代码]buffer[代码]格式 注: 0.4版本做了重构及优化,APIv2&v3以及Axios初始参数,均融合在一个型参上。 APIv3 Native下单 [代码]wxpay.v3.pay.transactions.native .post({/*文档参数放这里就好*/}) .then(({data: {code_url}}) => console.info(code_url)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 查询订单 [代码]wxpay.v3.pay.transactions.id['{transaction_id}'] .get({params: {mchid: '1230000109'}, transaction_id: '1217752501201407033233368018'}) .then(({data}) => console.info(data)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 关闭订单 [代码]wxpay.v3.pay.transactions.outTradeNo['1217752501201407033233368018'] .post({mchid: '1230000109'}) .then(({status, statusText}) => console.info(status, statusText)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 合单支付下单 [代码]wxpay.v3.combineTransactions.jsapi .post({/*文档参数放这里就好*/}) .then(res => console.info(res.data)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] H5下单 [代码]wxpay.v3.pay.transactions.h5 .post({/*文档参数放这里就好*/}) .then(({data: {h5_url}}) => console.info(h5_url)) .catch(console.error) [代码] 对账单下载及解析 [代码]const assert = require('assert') const {Hash: {sha1}} = require('wechatpay-axios-plugin') wxpay.v3.bill.tradebill.get({ params: { bill_date: '2021-02-12', bill_type: 'ALL', } }).then(({data: {download_url, hash_value}}) => wxpay.v3.billdownload.file.get({ params: (new URL(download_url)).searchParams, signed: hash_value, responseType: 'arraybuffer', })).then(res => { assert(sha1(res.data.toString()) === res.config.signed, 'verify the SHA1 digest failed.') console.info(Formatter.castCsvBill(res.data)) }).catch(error => { console.error(error) }) [代码] 创建商家券 [代码]wxpay.v3.marketing.busifavor.stocks .post({/*商家券创建条件*/}) .then(({data}) => console.info(data)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 查询用户单张券详情 [代码];(async () => { try { const {data: detail} = await wxpay.v3.marketing.busifavor .users.$openid$.coupons['{coupon_code}'].appids['wx233544546545989'] .get({openid: '2323dfsdf342342', coupon_code: '123446565767'}) console.info(detail) } catch({response: {status, statusText, data}}) { console.error(status, statusText, data) } } [代码] 服务商模式Native下单 [代码];(async () => { try { const res = await wxpay.v3.pay.partner.transactions.native({ sp_appid, sp_mchid, sub_mchid, description, out_trade_no, time_expire: new Date( (+new Date) + 33*60*1000 ), //after 33 minutes attach, notify_url, amount: { total: 1, } }) console.info(res.data.code_url) } catch (error) { console.error(error) } })() [代码] 支付即服务 [代码];(async () => { try { const {status, statusText} = await wxpay.v3.smartguide.guides.$guide_id$.assign .post({sub_mchid, out_trade_no}, {guide_id}) console.info(status, statusText) } catch({response: {status, statusText, data}}) { console.error(status, statusText, data) } } [代码] 商业投诉查询 [代码];(async () => { try { const res = await wxpay.v3.merchantService.complaints.get({ params: { limit : 5, offset : 0, begin_date : '2020-03-07', end_date : '2020-03-14', } }) console.info(res.data) } catch (error) { console.error(error) } })() [代码] 图片上传 [代码]const FormData = require('form-data') const {createReadStream} = require('fs') const imageMeta = { filename: 'hellowechatpay.png', // easy calculated by the command `sha256sum hellowechatpay.png` on OSX // or by require('wechatpay-axios-plugin').Hash.sha256(filebuffer) sha256: '1a47b1eb40f501457eaeafb1b1417edaddfbe7a4a8f9decec2d330d1b4477fbe', } const imageData = new FormData() imageData.append('meta', JSON.stringify(imageMeta), {contentType: 'application/json'}) imageData.append('file', createReadStream('./hellowechatpay.png')) ;(async () => { try { const res = await wxpay.v3.marketing.favor.media.imageUpload.post(imageData, { meta: imageMeta, headers: imageData.getHeaders() }) console.info(res.data.media_url) } catch (error) { console.error(error) } })() [代码] 查询优惠券详情 [代码];(async () => { try { const res = await wxpay.v3.marketing.favor.stocks.$stock_id$.post({ params: { stock_creator_mchid, }, stock_id, }) console.info(res.data) } catch(error) { console.error(error) } })() [代码] 优惠券委托营销 [代码](async () => { try { const res = await wxpay.v3.marketing.partnerships.build.post({ partner: { type, appid }, authorized_data: { business_type, stock_id } }, { headers: { [`Idempotency-Key`]: 12345 } }) console.info(res.data) } catch (error) { console.error(error) } })() [代码] 优惠券核销记录下载 [代码](async () => { try { let res = await wxpay.v3.marketing.favor.stocks.$stock_id$.useFlow.get({stock_id}) res = await wxpay.v3.billdownload.file.get({ params: (new URL(res.data.url)).searchParams, responseType: 'arraybuffer' }) // 备注:此接口下载的文件格式与商户平台下载的不完全一致,Formatter.castCsvBill解析有差异 console.info(res.data.toString()) } catch (error) { console.error(error) } })() [代码] 视频文件上传 [代码]const FormData = require('form-data') const {createReadStream} = require('fs') const videoMeta = { filename: 'hellowechatpay.mp4', // easy calculated by the command `sha256sum hellowechatpay.mp4` on OSX // or by require('wechatpay-axios-plugin').Hash.sha256(filebuffer) sha256: '1a47b1eb40f501457eaeafb1b1417edaddfbe7a4a8f9decec2d330d1b4477fbe', } const videoData = new FormData() videoData.append('meta', JSON.stringify(videoMeta), {contentType: 'application/json'}) videoData.append('file', createReadStream('./hellowechatpay.mp4')) ;(async () => { try { const res = await wxpay.v3.merchant.media.video_upload.post(videoData, { meta: videoMeta, headers: videoData.getHeaders() }) console.info(res.data.media_id) } catch (error) { console.error(error) } })() [代码] GZIP下载资金账单 [代码]const {unzipSync} = require('zlib') const assert = require('assert') const {Hash: {sha1}} = require('wechatpay-axios-plugin') ;(async () => { try { const {data: {download_url, hash_value}} = await wxpay.v3.bill.fundflowbill.GET({ params: { bill_date: '2020-02-12', bill_type: 'BASIC', tar_type: 'GZIP', } }) const {data} = await wxpay.v3.billdownload.file.GET({ params: (new URL(download_url)).searchParams, responseType: 'arraybuffer' }) // note here: previous `hash_value` was about the source `csv`, not the `gzip` data // so it needs unziped first, then to compare the `SHA1` degest const bill = unzipSync(data) assert.ok(hash_value === sha1(bill.toString()), 'SHA1 verification failed') console.info(Formatter.castCsvBill(bill)) } catch (error) { console.error(error) } })() [代码] APIv2 付款码(刷卡)支付 [代码]wxpay.v2.pay.micropay({ appid: 'wx8888888888888888', mch_id: '1900000109', nonce_str: Formatter.nonce(), sign_type: 'HMAC-SHA256', body: 'image形象店-深圳腾大-QQ公仔', out_trade_no: '1217752501201407033233368018', total_fee: 888, fee_type: 'CNY', spbill_create_ip: '8.8.8.8', auth_code: '120061098828009406', }) .then(res => console.info(res.data)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] H5支付 [代码]wxpay.v2.pay.unifiedorder({ appid: 'wx2421b1c4370ec43b', attach: '支付测试', body: 'H5支付测试', mch_id: '10000100', nonce_str: Formatter.nonce(), notify_url: 'http://wxpay.wxutil.com/pub_v2/pay/notify.v2.php', openid: 'oUpF8uMuAJO_M2pxb1Q9zNjWeS6o', out_trade_no: '1415659990', spbill_create_ip: '14.23.150.211', total_fee: 1, trade_type: 'MWEB', scene_info: JSON.stringify({ h5_info: { type:"IOS", app_name: "王者荣耀", package_name: "com.tencent.tmgp.sgame" } }), }).then(({data: {mweb_url}}) => console.info(mweb_url)).catch(console.error); [代码] 申请退款 [代码]wxpay.v2.secapi.pay.refund.post({ appid: 'wx8888888888888888', mch_id: '1900000109', out_trade_no: '1217752501201407033233368018', out_refund_no: '1217752501201407033233368018', total_fee: 100, refund_fee: 100, refund_fee_type: 'CNY', nonce_str: Formatter.nonce(), }) .then(res => console.info(res.data)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 现金红包 [代码]wxpay.v2.mmpaymkttransfers.sendredpack.POST({ nonce_str: Formatter.nonce(), mch_billno: '10000098201411111234567890', mch_id: '10000098', wxappid: 'wx8888888888888888', send_name: '鹅企支付', re_openid: 'oxTWIuGaIt6gTKsQRLau2M0yL16E', total_amount: 1000, total_num: 1, wishing: 'HAPPY BIRTHDAY', client_ip: '192.168.0.1', act_name: '回馈活动', remark: '会员回馈活动', scene_id: 'PRODUCT_4', }) .then(res => console.info(res.data)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 企业付款到零钱 [代码]wxpay.v2.mmpaymkttransfers.promotion.transfers({ appid: 'wx8888888888888888', mch_id: '1900000109', partner_trade_no: '10000098201411111234567890', openid: 'oxTWIuGaIt6gTKsQRLau2M0yL16E', check_name: 'FORCE_CHECK', re_user_name: '王小王', amount: 10099, desc: '理赔', spbill_create_ip: '192.168.0.1', nonce_str: Formatter.nonce(), }) .then(res => console.info(res.data)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 企业付款到银行卡-获取RSA公钥 [代码]wxpay.v2.risk.getpublickey({ mch_id: '1900000109', sign_type: 'MD5', nonce_str: Formatter.nonce(), }, { baseURL: 'https://fraud.mch.weixin.qq.com' }) .then(res => console.info(res.data)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 企业微信 企业微信的企业支付,数据请求包需要额外的签名,仅需做如下简单扩展适配,即可支持;以下签名注入函数所需的两个参数[代码]agentId[代码] [代码]agentSecret[代码]来自企业微信工作台,以下为示例值。 [代码]const agentId = 1001001 const agentSecret = 'from_wework_agent_special_string' const {Hash} = require('wechatpay-axios-plugin') [代码] 企业红包-注入签名规则 [代码]Wechatpay.client.v2.defaults.transformRequest.unshift(function workwxredpack(data, headers) { const {act_name, mch_billno, mch_id, nonce_str, re_openid, total_amount, wxappid} = data if (!(act_name && mch_billno && mch_id && nonce_str && re_openid && total_amount && wxappid)) { return data } data.workwx_sign = Hash.md5( Formatter.queryStringLike(Formatter.ksort({ act_name, mch_billno, mch_id, nonce_str, re_openid, total_amount, wxappid })), agentSecret, agentId ).toUpperCase() return data }) [代码] 发放企业红包 [代码]wxpay.v2.mmpaymkttransfers.sendworkwxredpack({ mch_billno: '123456', wxappid: 'wx8888888888888888', sender_name: 'XX活动', sender_header_media_id: '1G6nrLmr5EC3MMb_-zK1dDdzmd0p7cNliYu9V5w7o8K0', re_openid: 'oxTWIuGaIt6gTKsQRLau2M0yL16E', total_amount: 1000, wishing: '感谢您参加猜灯谜活动,祝您元宵节快乐!', act_name: '猜灯谜抢红包活动', remark: '猜越多得越多,快来抢!', mch_id: '1900000109', nonce_str: Formatter.nonce(), }) .then(res => console.info(res.data)) .catch(console.error) [代码] 向员工付款-注入签名规则 [代码]Wechatpay.client.v2.defaults.transformRequest.unshift(function wwsptrans2pocket(data, headers) { const {amount, appid, desc, mch_id, nonce_str, openid, partner_trade_no, ww_msg_type} = data if (!(amount && appid && desc && mch_id && nonce_str && openid && partner_trade_no && ww_msg_type)) { return data } data.workwx_sign = Hash.md5( Formatter.queryStringLike(Formatter.ksort({ amount, appid, desc, mch_id, nonce_str, openid, partner_trade_no, ww_msg_type })), agentSecret, agentId ).toUpperCase() return data }) [代码] 向员工付款 [代码]wxpay.v2.mmpaymkttransfers.promotion.paywwsptrans2pocket({ appid: 'wxe062425f740c8888', device_info: '013467007045764', partner_trade_no: '100000982017072019616', openid: 'ohO4Gt7wVPxIT1A9GjFaMYMiZY1s', check_name: 'NO_CHECK', re_user_name: '张三', amount: '100', desc: '六月出差报销费用', spbill_create_ip: '10.2.3.10', ww_msg_type: 'NORMAL_MSG', act_name: '示例项目', mch_id: '1900000109', nonce_str: Formatter.nonce(), }) .then(res => console.info(res.data)) .catch(console.error) [代码] 自定义打印日志 [代码]// APIv2 日志 Wechatpay.client.v2.defaults.transformRequest.push(data => (console.log(data), data)) Wechatpay.client.v2.defaults.transformResponse.unshift(data => (console.log(data), data)) // APIv3 日志 Wechatpay.client.v3.defaults.transformRequest.push((data, headers) => (console.log(data, headers), data)) Wechatpay.client.v3.defaults.transformResponse.unshift((data, headers) => (console.log(data, headers), data)) [代码] 获取RSA公钥 非标准接口地址,也可以这样调用 [代码]Wechatpay.client.v2.post('https://fraud.mch.weixin.qq.com/risk/getpublickey', { mch_id: '1900000109', nonce_str: Formatter.nonce(), sign_type: 'HMAC-SHA256', }) .then(({data}) => console.info(data)) .catch(({response}) => console.error(response)) [代码] XML形式通知应答 [代码]const {Transformer} = require('wechatpay-axios-plugin') const xml = Transformer.toXml({ return_code: 'SUCCESS', return_msg: 'OK', }) console.info(xml) [代码] aes-256-ecb/pcks7padding 解密 [代码]const {Aes: {AesEcb}, Transformer, Hash} = require('wechatpay-axios-plugin') const secret = 'exposed_your_key_here_have_risks' const xml = '<xml>' + ... '</xml>' const obj = Transformer.toObject(xml) const res = AesEcb.decrypt(obj.req_info, Hash.md5(secret)) obj.req_info = Transformer.toObject(res) console.info(obj) [代码] 加密 [代码]const obj = Transformer.toObject(xml) const ciphertext = AesEcb.encrypt(obj.req_info, Hash.md5(secret)) console.assert( obj.req_info === ciphertext, `The notify hash digest should be matched the local one` ) [代码] APIv2数据签名 JSAPI [代码]const {Hash, Formatter} = require('wechatpay-axios-plugin') const v2Secret = 'exposed_your_key_here_have_risks' const params = { appId: 'wx8888888888888888', timeStamp: `${Formatter.timestamp()}`, nonceStr: Formatter.nonce(), package: 'prepay_id=wx201410272009395522657a690389285100', signType: 'HMAC-SHA256', } params.paySign = Hash.sign(params.signType, params, v2Secret) console.info(params) [代码] APP [代码]const {Hash, Formatter} = require('wechatpay-axios-plugin') const v2Secret = 'exposed_your_key_here_have_risks' const params = { appid: 'wx8888888888888888', partnerid: '1900000109', prepayid: 'WX1217752501201407033233368018', package: 'Sign=WXPay', timestamp: `${Formatter.timestamp()}`, noncestr: Formatter.nonce(), } params.sign = Hash.sign('MD5', params, v2Secret) console.info(params) [代码] APIv3数据签名 JSAPI [代码]const {Rsa, Formatter} = require('wechatpay-axios-plugin') const privateKey = require('fs').readFileSync('/your/merchant/priviate_key.pem') const params = { appId: 'wx8888888888888888', timeStamp: `${Formatter.timestamp()}`, nonceStr: Formatter.nonce(), package: 'prepay_id=wx201410272009395522657a690389285100', signType: 'RSA', } params.paySign = Rsa.sign(Formatter.joinedByLineFeed( params.appId, params.timeStamp, params.nonceStr, params.package ), privateKey) console.info(params) [代码] 商家券-小程序发券v2版签名规则 [代码]const {Hash, Formatter} = require('wechatpay-axios-plugin') const v2Secret = 'exposed_your_key_here_have_risks' // flat the miniprogram data transferring structure for sign const busiFavorFlat = ({send_coupon_merchant, send_coupon_params = []} = {}) => { return { send_coupon_merchant, ...send_coupon_params.reduce((des, row, idx) => ( Object.keys(row).map(one => des[`${one}${idx}`] = row[one]), des ), {}), } } // the miniprogram data transferring structure const busiFavor = { send_coupon_params: [ {out_request_no:'1234567',stock_id:'abc123'}, {out_request_no:'7654321',stock_id:'321cba'}, ], send_coupon_merchant: '10016226' } busiFavor.sign = Hash.sign('HMAC-SHA256', busiFavorFlat(busiFavor), v2Secret) console.info(busiFavor) [代码] 商家券-H5发券v2版签名规则 [代码]const {Hash, Formatter} = require('wechatpay-axios-plugin') const v2Secret = 'exposed_your_key_here_have_risks' const params = { stock_id: '12111100000001', out_request_no: '20191204550002', send_coupon_merchant: '10016226', open_id: 'oVvBvwEurkeUJpBzX90-6MfCHbec', coupon_code: '75345199', } params.sign = Hash.sign('HMAC-SHA256', params, v2Secret) console.info(params) [代码] 常见问题 Q: APIv3消息通知,[代码]AES-256-GCM[代码]加密字段,应该如何解密? 官方文档有介绍,APIv3平台证书及消息通知关键信息均使用[代码]AesGcm[代码]加解密,依赖[代码]APIv3密钥[代码],商户侧解密可参考[代码]bin/cli/cert.js[代码]证书下载工具,例如: [代码]AesGcm.decrypt(nonce, secret, ciphertext, aad); [代码] Q: 敏感信息或者幂等操作要求额外头信息上送时,应该如何构建请求参数? [代码]DELETE[代码]/[代码]GET[代码]请求的第一个参数,[代码]POST[代码]/[代码]PUT[代码]/[代码]PATCH[代码]请求的第二个参数,是 AxiosRequestConfig 对象,可以按需上送额外头参数,例如: [代码]wxpay.v3.applyment4sub.applyment.$noop$( {}, { noop: '', headers: { 'Wechatpay-Serial': '123456' } }, ).then(console.info).catch(console.error); [代码] 可参考 #17 Q: 接口地址为slash([代码]/[代码])结尾的,应该如何构建请求参数? 动态参数[代码]uri_template[代码]或者属性[代码]property[代码]方式构建,可参考 #16 单元测试 [代码]npm install && npm test[代码] 技术交流 如果遇到困难或建议可以 提ISSUE 或 加群,交流技术,分享经验。 QQ群: 684379275 文末打印一波示例方法链 [代码][Function (anonymous)] { v2: [Function: v2] { risk: [Function: v2/risk] { getpublickey: [Function: v2/risk/getpublickey] }, pay: [Function: v2/pay] { micropay: [Function: v2/pay/micropay] }, secapi: [Function: v2/secapi] { pay: [Function: v2/secapi/pay] { refund: [Function: v2/secapi/pay/refund] } }, mmpaymkttransfers: [Function: v2/mmpaymkttransfers] { sendredpack: [Function: v2/mmpaymkttransfers/sendredpack], promotion: [Function: v2/mmpaymkttransfers/promotion] { transfers: [Function: v2/mmpaymkttransfers/promotion/transfers], paywwsptrans2pocket: [Function: v2/mmpaymkttransfers/promotion/paywwsptrans2pocket] }, sendworkwxredpack: [Function: v2/mmpaymkttransfers/sendworkwxredpack] } }, v3: [Function: v3] { pay: [Function: v3/pay] { transactions: [Function: v3/pay/transactions] { native: [Function: v3/pay/transactions/native], id: [Function: v3/pay/transactions/id] { '{transaction_id}': [Function: v3/pay/transactions/id/{transaction_id}] }, outTradeNo: [Function: v3/pay/transactions/out-trade-no] { '1217752501201407033233368018': [Function: v3/pay/transactions/out-trade-no/1217752501201407033233368018] } }, partner: [Function: v3/pay/partner] { transactions: [Function: v3/pay/partner/transactions] { native: [Function: v3/pay/partner/transactions/native] } } }, marketing: [Function: v3/marketing] { busifavor: [Function: v3/marketing/busifavor] { stocks: [Function: v3/marketing/busifavor/stocks], users: [Function: v3/marketing/busifavor/users] { '$openid$': [Function: v3/marketing/busifavor/users/{openid}] { coupons: [Function: v3/marketing/busifavor/users/{openid}/coupons] { '{coupon_code}': [Function: v3/marketing/busifavor/users/{openid}/coupons/{coupon_code}] { appids: [Function: v3/marketing/busifavor/users/{openid}/coupons/{coupon_code}/appids] { wx233544546545989: [Function: v3/marketing/busifavor/users/{openid}/coupons/{coupon_code}/appids/wx233544546545989] } } } } } }, favor: [Function: v3/marketing/favor] { media: [Function: v3/marketing/favor/media] { imageUpload: [Function: v3/marketing/favor/media/image-upload] }, stocks: [Function: v3/marketing/favor/stocks] { '$stock_id$': [Function: v3/marketing/favor/stocks/{stock_id}] { useFlow: [Function: v3/marketing/favor/stocks/{stock_id}/use-flow] } } }, partnerships: [Function: v3/marketing/partnerships] { build: [Function: v3/marketing/partnerships/build] } }, combineTransactions: [Function: v3/combine-transactions] { jsapi: [Function: v3/combine-transactions/jsapi] }, bill: [Function: v3/bill] { tradebill: [Function: v3/bill/tradebill], fundflowbill: [Function: v3/bill/fundflowbill] }, billdownload: [Function: v3/billdownload] { file: [Function: v3/billdownload/file] }, smartguide: [Function: v3/smartguide] { guides: [Function: v3/smartguide/guides] { '$guide_id$': [Function: v3/smartguide/guides/{guide_id}] { assign: [Function: v3/smartguide/guides/{guide_id}/assign] } } }, merchantService: [Function: v3/merchant-service] { complaints: [Function: v3/merchant-service/complaints] }, merchant: [Function: v3/merchant] { media: [Function: v3/merchant/media] { video_upload: [Function: v3/merchant/media/video_upload] } } } } [代码] 变更历史 v0.5.5 (2021-04-13) 优化文档,[代码]证书[代码]相关名词与官方文档保持一致 优化代码,使用ES6 [代码]Reflect.set[代码]代替[代码]param-reassign[代码],性能更高 新增函数[代码]Hash.hmac[代码]方法,广度支持[代码]Hash-based Message Authentication Code[代码] 调整函数[代码]Hash.hmacSha256[代码]为不推荐方法,内部改写为固定[代码]Hash.hmac[代码]调用 调整CLI [代码]req <uri>[代码]成功调用仅返回[代码]{config, headers, data}[代码]数据结构 v0.5.4 (2021-04-08) 优化CLI,[代码]wxpay crt[代码] 下载平台证书仅在成功验签完成后写入文件 优化文档,[代码]AesGcm[代码] 解密示例 优化内部[代码]chain[代码]逻辑,遵循 [代码]RFC3986[代码] 规范,[代码]baseURL[代码]支持带部分路径的海外接入点 优化代码[代码]SonarQube[代码]检测结果[代码]3A+0.5%[代码] v0.5.3 优化CLI,[代码]wxpay <uri>[代码] 向前兼容以支持slash(/)结尾的请求,形如 [代码]v3/applyment4sub/applyment/[代码] v0.5.2 优化CLI,[代码]wxpay <uri>[代码] 现在支持型如 [代码]v2.pay.micropay[代码], [代码]v3.pay.transactions.native[代码] 调用 优化[代码]README[代码]文档,适配最新CLI用法;增加APIv3消息通知QA章节;增加技术交流QQ群说明 v0.5.1 优化CLI,可以直接 [代码]wxpay <uri>[代码] 发起请求 优化[代码]README[代码]文档,适配最新CLI用法 v0.5.0 新增命令行方式与微信支付接口交互工具 调整可选依赖包为[代码]peerDependencies[代码],使用完整功能需手动安装 [代码]form-data[代码] 或/及 [代码]yargs[代码] v0.4.6 使用最新版[代码]eslint[代码]及[代码]eslint-config-airbnb-base[代码] 增加[代码]utils.merge[代码]依赖函数测试校验 v0.4.5 支持APIv2版的俩账单下载,调用方法与APIv3类同; 增加测试用例覆盖,初始化参数[代码]secret[代码](for APIv2)如未设置,[代码]HMAC-SHA256[代码]数据签名时,可能引发 #14 v0.4.4 优化[代码]Wechatpay[代码]在多次实例化时赋值[代码]Symbol(CLIENT)[代码]异常问题,增加[代码]wechatpay.test.js[代码]测试用例覆盖 v0.4.3 支持 企业微信-企业支付 链式调用,需要额外注入签名规则,见上述文档用法示例 v0.4.2 文件名大小写问题 #11 感谢 @LiuXiaoZhuang 报告此问题 v0.4.1 解决了一个[代码]AES-GCM[代码]在[代码]Node10[代码]上的解密兼容性问题,程序在[代码]Node10[代码]上有可能崩溃,建议[代码]Node10[代码]用户升级至此版本 v0.4.0 重构 [代码]Wechatpay[代码] 类,同时支持 APIv2&v3’s 链式调用 改变 [代码]Wechatpay.client[代码] 返回值为[代码]Wechatpay.client.v3[代码],[代码]Wechatpay.client.v2[代码] 为 [代码]xmlBased[代码] 接口客户端 废弃 [代码]withEntities[代码] 方法,其在链式多次调用时,有可能达不到预期,详情见 #10,感谢 @ali-pay 报告此问题 README 文档中文化 完善补缺 [代码]tsd[代码] 声明 v0.3.4 Typed and tips on [代码]Wechatpay[代码] class(#9), thanks @ipoa v0.3.3 Upgrade Axios for the CVE-2020-28168 v0.3.2 Optim: Let [代码]Aes.pkcs7.padding[代码] strictly following the [代码]rfc2315[代码] spec Optim: Better of the [代码]Hash.md5[代码] and [代码]Hash.hmacSha256[代码] Coding comments and README v0.3.1 Optim: new param on [代码]xmlBased({mchid})[代码], while passed in, then [代码]Transformer.signer[代码] doing the [代码]assert[代码] with the post data. Feature: Customize the HTTP [代码]User-Agent[代码]. Refactor: Split [代码]aes.js[代码] as of [代码]Aes[代码], [代码]AesGcm[代码] and [代码]AesEcb[代码] classes for [代码]aes-256-ecb/pkcs7padding[代码] algo. v0.3.0 Feature: The XML based API requests. v0.2.3 Optim: Coding quality. v0.2.2 Fix: #8 [代码]verfier[代码] on the [代码]204[代码] status case. v0.2.1 Optim: Back compatible for [代码]12.4.0[代码] < [代码]Node[代码] ≧ [代码]10.15.0[代码]. v0.2.0 Feature: [代码]OOP[代码] developing style of the wechatpay APIv3. v0.1.0 Optim: Toggle the [代码]Nodejs[代码] version ≧ [代码]10.15.0[代码]. Optim: Documentation and coding comments. v0.0.9 Feature: definition of the [代码]Typescript[代码] v0.0.8 Optim: on [代码]castCsvBill[代码], drop the [代码]trim[代码] on each rows Optim: on [代码]response[代码] validation, checking ± 5 mins first then to [代码]Rsa.verify[代码] Optim: moved the [代码]commander[代码] optional dependency, because it’s only for the [代码]CLI[代码] tool Feature: shipped 99 tests([代码]npm test[代码]) v0.0.7 Feature: billdownload and castCsvBill eslint enabled ([代码]npm run lint[代码]) v0.0.6 Chinese document v0.0.5 Renew document and codes comments v0.0.4 Feature: certificate downloader, deps on [代码]commander[代码] v0.0.3 Feature: media file upload, optional deps on [代码]form-data[代码] v0.0.2 Feature: Assert the response’s timestamp ± 5 mins Refactor as CommonJS style(#6) Limits the communicating parameters(#7) Coding styles(#5) Coding comments and Document(#4, #3, #2, #1) v0.0.1 Init ES2015+ style License The MIT License (MIT) Copyright © 2020-2021 James ZHANG(TheNorthMemory) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
2021-05-10 - 微信支付V3文档中,上传图片接口,对图片进行SHA256计算,结果编码是什么?
[图片] 返回结果是hex还是二进制还是base64呀?这里没写清楚
2020-03-16 - 关于新版隐私协议接口wx.onNeedPrivacyAuthorization的适配解读以及实现代码
官方公告地址: https://developers.weixin.qq.com/community/develop/doc/00042e3ef54940ce8520e38db61801 目前,开发工具或者体验版的小程序,调试基础库如果是2.33.0及以上就得适配了,线上版本9月15日之后生效,所以这之前需要尽快改完,发布一版,否则到了9月15号之后 线上就会生效报错了。 其实改起来也很简单,以下是实现步骤和代码: 1、首先看一下这个网址,里边包含涉及到的隐私的接口,这些接口都要适配一下 https://developers.weixin.qq.com/miniprogram/dev/framework/user-privacy/miniprogram-intro.html [图片] 在以上接口用到的页面,需要画一下类似上边的弹窗(这个弹窗可以全局定义个组件,方便多个页面共用),然后里边蓝字可以点击后调用wx.openPrivacyContract(Object object)接口即可,会自动跳转打开隐私协议页面。 拒绝按钮可以加一个点击事件,然后在事件里这样写 [图片] 同意按钮比较特殊,布局需要用button这样写,记得给button加一个Id [图片] 然后在handleAgreePrivacyAuthorization里就可以获取到点击事件,这样写 [图片] 2、最后需要在onLoad或者onShow里加上以下监听代码,在这里边让自定义的隐私弹窗显示出来即可。 [图片] 以上代码加上就可以了,如果业务逻辑用到了需要判断是否授权过,可以加上 wx.getPrivacySetting(Object object)去获取是否授权过,用不到可以不加这个判断。
2023-08-16 - 小程序scroll-view翻转后 scroll-into-view的替代方案
背景 腾讯云医小程序有医患聊天会话的场景,由于会话场景存在查询历史消息的场景,小程序中按照常规思路加载历史消息时会出现跳动的问题;跳动的原因是由于在’顶部’插入dom,会使得后面的dom被往后面推,然后重新设置scroll-top或者scrol-into-view从而导页面出现跳动;我们尝试采用【 前端开发中聊天场景的体验优化】文章中的方案处理跳动的场景。该文章的核心观点将scroll-view元素通过设置css样式 transform: rotateX(180deg); 进行翻转,这样将历史消对应的dom结构放在尾部,当添加更多的历史消息(dom)时,由于dom是添加在尾部很优雅的绕过了插入历史消息跳动的场景。但是当我们按照这种方式实现后,发现scroll-view元素提供的scroll-into-view属性不好使了。因此有了本文通过计算scrollTop值设置scrollTop来达到相同目的。 复现该问题的小程序代码片段:代码片段 目前已经反馈给官方(官方已确认是内部组件实现暂不支持翻转的场景 基础知识介绍 计算scrollTop涉及到一些web和小程序的基础知识,后面针对这些基础点进行简单介绍 .scroll-into-view 微信小程序提供的scroll-view元素提供了属性 scroll-into-view,该属性的作用是可以将指定dom滚动到scroll-view可见区域内 [图片] 关于boundingClientRect 下图是MDN解释该属性时提供的,从下图中可以看到top/bottom/left/right的值是元素的左上角和右下角相对于视口左上角的水/垂直距离 [图片] 为了更深入理解这些值。给出了一个简易的demo(代码片段),获取实例元素的的boundingClientRect的值后,可以看到这些值是根据元素的border边界进行计算的 [图片] [图片] [图片] 值得注意的是,当元素处于一个滚动区域内部,left/top值是考虑滚动操作的即包含滚动距离的(参考MDN 另外,当我们把容器元素又或者元素自身设置 transform: rotateX/Y(180deg):不会导致top和bottom的值互换(left与right的值互换); 总会有这样的结论,当dom元素的宽度和高度不为0时,top值一定小于bottom值,left值一定小于right值 关于scrollTop 当一个容器的内容的高度大于其容器高度时,overflow不为visible/hidden时,则会出现滚动条。出现滚动条后,内容区域则可以滚动,此时scrollTop的值是容器可视区域的顶部到内容区域顶部的距离,见下面示意图。 [图片] 值得注意的是,滚动条出现在盒模型中的content区域,见下图滚动条不会覆盖padding/border部分。因此上面说到内容区域高度超过容器高度并不严谨,严谨的说法应该是超过容器的content区域的高度。 [图片] 如果此时给容器设置css样式: transform: rotateX(180deg); 即沿垂直方向翻转180度,scrollTop的值会发生变化吗。 下面我们看下实际的对比效果,为了方便查看滚动条的效果,给滚动条轨道(红色部分)以及滑块(黑色部分)添加了背景色,发现整个元素包括滚动条在内一并进行了翻转。 正常情况(左侧),应用翻转css样式(右侧) [图片] [图片] 翻转后的scrollTop值示意图 [图片] 通过计算scrollTop值来模拟scroll-into-view效果(针对scroll-view翻转的场景j) 由于boundingClinetRect的值是包含border边界的,因此当数据项包含padding,border等区域不会影响这里的计算过程,可以认为下面示意图中的数据项部分的边界是border边界; 由于滚动条是出现在content区域,因此容器元素的的border-top/padding-top不为0时,会影响计算流程,因此这里分为两种情况进行介绍: 2.1 假设scroll-view元素的的border-top/padding-top为0 2.2 假设scroll-view元素的的border-top/padding-top不为0 border-top/padding-top为0的情况 为了方便说明计算过程,我定义三种状态,初始态、中间态、最终态 示意图中的区域说明 白色背景的为视口, 绿色背景的是容器(scroll-view)的可视区域, 灰色区域是内容区域,并且内容区域的高度超过了容器的高度, 红色区域是一个数据项 [图片] 现在的目标是将数据项从初始态滚动到最终态即scroll-into-view的效果:border的上边界与可视区域上边界对齐 第一步:从初始态达到中间态 根据上面关于scrollTop的描述,这里如果scrollTop的值是targetDistance即数据项的底部到内容区域的底部的距离,就可以达到中间态,因此现在的目标是求targetDistance 初始状态的已知变量 初始状态下的的scrollTop值:currentScrollTop (由于容器发生翻转,所以scrollTop视觉上指向容器下方) 数据项的boundingClientRect.bottom为 itemBottom 容器的boundingClientRect.bottom为 contianerBottom 通过示意图很容易得出 [代码]targetDistance = currentScrollTop + (containerBottom - itemBottom) [代码] 第二步:从中间到达最终态 已知变量:容器高度:containerHeight、数据项高度:itemHeight 最终态是数据项的顶部距离容器顶部,从示意图中看到中间态到最终态的scrollTop是减少了的,减少的值其实就是cotainerHeight - itemHeight 经过第一步和第二步我们就可以得到scrollTop的计算公式 [代码]let itemScrollTop = currentScrollTop + containerBottom - itemBottom itemScrollTop -= (containerHeight - itemHeight) => itemScrollTop = currentScrollTop + containerBottom - itemBottom - (containerHeight - itemHeight) [代码] border-top/padding-top不为0的情况 [图片] 根据上面第一种情况的介绍的思路,很容易得到下面结果,不再赘述(X 就是容器padding-top + border-top的值) [代码]let itemScrollTop = currentScrollTop + containerBottom - itemBottom - X itemScrollTop -= (containerHeight - itemHeight - X) => itemScrollTop = currentScrollTop + containerBottom - itemBottom - X - (containerHeight - itemHeight - X) => itemScrollTop = currentScrollTop + containerBottom - itemBottom - (containerHeight - itemHeight) [代码] 【结论】两种情况最终的计算过程是一样的,因此在实现的过程中不需要进行区分 代码实现 代码片段见:https://developers.weixin.qq.com/s/y1X11dmr7AqC 视图层代码 [代码]{{item.content}} #scroll-view { position: absolute; top: 50px; bottom: 50px; width: 100%; background-color: rgba(0, 0, 0, 0.1); // 关键case transform: rotateX(180deg); } [代码] 逻辑层核心代码 [代码]scrollTo () { const itemId = '#item_id_50' const containerId = '#scroll-view' Promise.all([this._queryBoundingClient(itemId), this._getScrollInfo(containerId)]) .then((res = [[[{}]], {}]) => { const [[[ { bottom: itemBottom, height: itemHeight }]], { bottom: containerBottom, scrollTop, height: containerHeight }] = res let itemScrollTop = containerBottom - itemBottom + scrollTop itemScrollTop -= (containerHeight - itemHeight) this.setData({ scrollTop: itemScrollTop }) }) }, _queryBoundingClient (selector) { // 获取目标dom的相关位置/尺寸信息 return new Promise(resolve => { const query = this.createSelectorQuery(); query.selectAll(selector).boundingClientRect(); query.exec(resolve); }) }, _getScrollInfo (idSelector) { // 用来获取容器层相关位置/尺寸信息 return new Promise(resolve => { const query = this.createSelectorQuery() query.select(idSelector).boundingClientRect() query.select(idSelector).scrollOffset() query.exec((res = [{}, {}]) => { const [{ top, bottom, height }, { scrollHeight, scrollTop }] = res const scrollInfo = { scrollTop, scrollHeight, top, bottom, height } resolve(scrollInfo) }) }) } [代码]
2023-03-23 - onShow与onLoad的一些理解和实践
基本介绍onShow、onLoad与onReady都是小程序页面生命周期函数。 onLoad 在页面加载时调用,仅一次; onShow页面显示/切入前台时触发,两个生命周期非阻塞式调用。 onReady 是页面初始化数据已经完成后调用的,并不意味着onLoad和onShow执行完毕。 调用顺序是onLoad > onShow > onReady 根据对应的执行机制,我们预期有三种执行的逻辑 A. 页面每次出现都会执行 从其他页面返回手机锁屏唤醒,重新看到小程序页面把当前小程序页面重写切换到前台(多任务)B. 页面加载后只需执行一次(页面第一次载入) C. 只在页面非第一次执行时才执行(A情况的子集,页面非第一次展示时) 需求与问题逻辑1: 因为onLoad和onShow是非阻塞执行的,当我们有一个这样的需求:页面载入执行A方法,页面展示执行B、C、D方法时,A需要在BCD之前执行,此时把A放在onLoad中,BCD放在onShow中就无法实现需求 逻辑2: 还有一种需求是:页面第一次执行A,非第一次执行R-A,这里onLoad和onShow并没有非第一次的逻辑,需要手动判断。 一种实践方法下面是纯粹使用onShow代替onLoad,完成所有逻辑的示例,保证了业务逻辑的执行顺序可控。 options获取使用其他方式代替。 为了保持onShow中逻辑的清晰性,尽量使用EventChannel去替代原本onShow+globalData的逻辑。 data:{ first: true }, async onShow(){ //代替onLoad中的options的获取 const pages = getCurrentPages(); const currentPage = pages[pages.length - 1]; const options = currentPage.options; this.funD() // C2 页面每次都调用的逻辑 if(this.data.first){ this.data.first = false; await this.funA(); //A 仅在页面初次调用的逻辑(按需是否阻塞调用) }else{ await this.funB(); //B 仅在页面非初次时调用的逻辑 } await this.funC(); //C1 页面每次都调用的逻辑 } 另外一种使用实践data:{ first: true } onShow(){ this.funD() //页面每次都调用的逻辑(仅非阻塞) if(!this.data.first){ this.funC() //仅在页面非初次时调用的逻辑 } await this.funE() //页面每次都调用的逻辑(可阻塞,可非阻塞) }, onLoad(){ //仅在页面初次调用的逻辑 this.funA(); await this.funB(); } onReady(){ this.data.first = false; } 如有错误,恳请指出。
2022-09-23 - 小程序开发常用的几个NPM包
小程序开发常用的几个NPM包 ~ 1)js-base64 场景 平时在做支付的时候会做一下加解密的工作,用于数据的验证用 [图片] ~ 示例 [图片] ~ 2)jsencrypt 场景 目前的应用场景是在用户注册或登录的时候,用公钥对密码进行加密,再去传给后台,后台用私钥对加密的内容进行解密,然后进行密码校验或者保存到数据库。 [图片] ~ 示例 [图片] ~ 3)urijs 场景 日常操作组装链接使用 [图片] ~ ~ 示例 buildQuery [图片] ~
2022-09-28 - 小程序中better-mock的使用
背景 小程序开发者工具本身自带api mock工具,但是在真机调式的时候mock无法生效,所以想寻找一种可真机调试的mock方案。 better-mock介绍 web端有很好用的mockjs,但已经停止维护了。better-mock在mockjs的基础上改进,并兼容了小程序。原理上就是对小程序的request方法拦截,并返回自定义的mock数据。 如何使用 1.可以使用npm直接安装better-mock,或者直接下载release文件,然后引入。 [代码]const Mock = require('better-mock/dist/mock.mp.js') // 引入better-mock Mock.mock('http://example.com/path/to', { // mock接口 phone: '@PHONE' }) wx.request({ url: 'http://example.com/path/to', success (res) { console.log(res.data.phone) // 13687529123 } }) [代码] 2.使用方法与mockjs基本一致 [代码]Mock.mock('/mock_url', 'post', { 'list|1-10': [{ 'id|+1': 1, 'email': '@EMAIL' }] }) [代码] 返回数据如下: [代码]{ "statusCode":200, "list": [ { "id": 1, "email": "g.tapnqamfka@tiboen.ad" }, { "id": 2, "email": "x.sdha@ysyudl.dm" } ] } [代码] 参考文档:https://lavyun.github.io/better-mock/document/miniprogram.html#介绍 Tips 实际使用过程中还遇到了一些其他的问题: 1.测试时需要返回200以外状态码,测试异常系。除了statusCode,还需要返回自定义的code。 解决方案:修改了mock.mp.js文件(8427行左右),mock时传入statusCode,再修改返回值,确保可以方便地调整返回状态码。同理,可以自定义返回的数据结构。 [图片] 传入示例如下: [图片] 2.单页面请求多个服务器,只想启用部分接口的mock 解决方案: 不同服务器使用单独的mock文件,在app.js中根据需要启用不同mock。每个文件增加单个接口mock是否启用的控制。 启用不同服务器mock: [图片] 通过config.POST_TEST控制单个接口是否启用: [图片]
2022-10-08 - 高阶性能渲染-wxs
我们永远没有资格说放弃,因为这是属于我们的年华,应该开出耀眼的繁花。 这里主要讲解两点使用方式 wxs --> 数据处理使用 wxs --> 拖拽使用 wxs介绍 1.先看看官方如何介绍的 ,如下 点我可以查看更多官方文档地址 WXS 不依赖于运行时的基础库版本,可以在所有版本的小程序中运行。 WXS 与 JavaScript 是不同的语言,有自己的语法,并不和 JavaScript 一致。 WXS 的运行环境和其他 JavaScript 代码是隔离的,WXS 中不能调用其他 JavaScript 文件中定义的函数,也不能调用小程序提供的API。 WXS 函数不能作为组件的事件回调。 由于运行环境的差异,在 iOS 设备上小程序内的 WXS 会比 JavaScript 代码快 2 ~ 20 倍。在 android 设备上二者运行效率无差异。 总的来说,我主要看中它快和方便 (有种在wxml写函数的感觉),并且处理函数内容后渲染不需要去调用setData,可以节省在请求数据返回后写大量的逻辑函数或者遍历方法去处理事务,所以我才拿出来讲解。 wxs数据处理使用 使用我习惯现在utils文件夹里创建一个common.wxs,然后在使用的wxml引入该文件。具体使用也很简单,如下 在你定义的common.wxs文件里,写你需要处理的函数,然后使用export导出 [代码] var getMax = function(array) { var max = undefined; for (var i = 0; i < array.length; ++i) { max = max === undefined ? array[i] : (max >= array[i] ? max : array[i]); } return max; } module.exports = { getMax: getMax }; [代码] 在.wxml文件需要的地方引入该.wxs文件,其中<wxs>标签包含2个重要属性,[代码]module[代码]和[代码]src[代码]<br> module表示你要导出wxs的方法集合,在wxml里面可以使用该方法名去使用具体的函数<br> src表示你.wxs路径位置引入,代码如下 [代码]<wxs module="common" src="../../utils/common.wxs"></wxs> <view> <text>{{common.getMax(arrNumber)}}</text> </view> [代码] 上面代码里[代码]arrNumber[代码]是你页面.js文件里面定义的数据 [代码]data: { arrNumber: [1,2,9,2,1,5,7] } [代码] 就是如此简单,你已经学会了使用wxs处理数据了,接下来我们来点难的,使用wxs做一个可以随便拖动又不影响小程序性能的手势拖拽事件 wxs拖拽使用 你是否还在使用setData控制元素?为什么不能频繁使用setData,点我查看详情然后通过输出手势坐标来移动元素具体位置?如果是建议你该换个方法了,推荐使用wxs,让你小程序性能上一个档次。 解决思路如下: 我们先在页面的.wxml文件书写初始化的样式内容; 给[代码]view[代码]标签绑定手势事件,其中[代码]touchmove[代码]我使用了阻止冒泡事件绑定是为了防止在苹果机中,元素移动会带着屏幕一起移动,导致滑动手势突然中断不流畅, [代码]clickLive[代码]事件我也用了阻止冒泡事件是为了触发子元素的事件防止点击被父元素拦截而使用,代码如下: [代码]<wxs module="comm" src="../../utils/common.wxs"></wxs> <view class="enterLive liveMove" data-maxWidth="{{windowWidth}}" data-maxHeight="{{windowHeight}}" bind:touchstart="{{comm.liveTouchmove}}" catch:touchmove="{{comm.liveTouchmove}}" bind:touchend="{{comm.liveTouchmove}}"> <view class="live-block" catch:tap="{{comm.clickLive}}"> <image class="liveEnter-img" mode="aspectFit" src="https://img0.baidu.com/it/u=401284325,23907343&fm=26&fmt=auto&gp=0.jpg" /> </view> </view> [代码] 我们发现上面的代码有[代码]windowWidth,windowHeight[代码]两个参数,这是在页面的.js中,是计算当前页面的宽高,用于防止滑块滑出视野范围内,导致无法滑动回来,其中下面代码[代码]getApp().globalData.systemInfo[代码]是读取全局文件[代码]app.js[代码]里面拿值的,那里我有获取系统参数设为全局保存, 而[代码]bindEnter[代码]事件是用于待会在[代码].wxs[代码]里面可以触发.js文件内函数示例 [代码]data: { windowWidth: 375, windowHeight: 667, }, ready: function() { let systemInfo = getApp().globalData.systemInfo; let width = 150 * systemInfo.windowWidth / 750; let height = 150 * systemInfo.windowWidth / 750; if (systemInfo) { this.setData({ windowWidth: systemInfo.windowWidth - parseInt(width), windowHeight: systemInfo.windowHeight - parseInt(height) }) } }, bindEnter(e) { wx.showToast({ title: '你点击到我啦~', icon: 'none' }) }, [代码] 接下来我们将书写.wxs文件,处理手势输出内容,使页面的滑块可以平静的滑动; 认真看的同学会发现页面的[代码]touchstart,touchmove,touchend[代码]事件我都是绑定同一个函数[代码]liveTouchmove[代码]这里是我想节约函数名称,统一使用一个函数,然后在输出的回调里面判断当前是什么手势 这里的滑块滑动逻辑是:当输出是touchstart手势时,我们记住当前滑块的[代码]starLeft,starTop[代码]值及坐标[代码]starX,starY[代码]值 当我们判断当前是[代码]touchmove[代码]手势时,我们就用当前的x,y坐标值减去开始starX和starY坐标值,得到一个差值[代码]diffX,diffY[代码] 然后我们使用这个[代码]diffX,diffY[代码]差值分别加上滑块刚开始的[代码]starLeft,starTop[代码]值,即可得到我们滑块当前移动的位置 最后我们通过条件判断当前是否超出屏幕即可,我这里做多一步,就是滑块永远停留左右两边,所以要多算一次滑块停留是偏离那一边,在通过[代码]ownerInstance.selectComponent('.liveMove').setStyle[代码]可以给页面元素添加行内样式。 滑动滑动逻辑已经讲完了,就是这么简单 如果你想通过[代码].wxs[代码]函数触发[代码].js[代码]文件内的函数,这里有提供[代码]ownerInstance.callMethod("bindEnter")[代码]方法给你触发.js里面的函数,其中[代码]ownerInstance[代码]是绑定页面函数的第二个回调参数,[代码]callMethod[代码]是官方提供的一个方法,[代码]bindEnter[代码]是.js文件里自己命名的函数方法名称,整体代码如下:更多wxs内置方法,请查看官方文档 [代码]/** * 滑块计算位置 */ var starLeft = 0; var starTop = 0; var starX = 0; var starY = 0; function liveTouchmove(event, ownerInstance) { // console.log(JSON.stringify(event)) // console.log(JSON.stringify(ownerInstance)) var left = 0, top = 0; var diffX = 0, diffY = 0; if(event.type === 'touchstart') { starLeft = event.currentTarget.offsetLeft; starTop = event.currentTarget.offsetTop; starX = event.changedTouches[0].clientX; starY = event.changedTouches[0].clientY; left = starLeft; top = starTop; } else { diffX = event.changedTouches[0].clientX - starX; diffY = event.changedTouches[0].clientY - starY; left = starLeft + diffX; top = starTop + diffY; var maxWidth = event.currentTarget.dataset.maxwidth; var maxHeight = event.currentTarget.dataset.maxheight; if (left > maxWidth) { left = maxWidth; } if (top > maxHeight) { top = maxHeight; } if (left <= 0) { left = 0; } if (top <= 0) { top = 0; } } if (event.type === 'touchend') { if (maxWidth / 2 < left) { left = "calc(100% - 3% - 152rpx)"; } if (maxWidth / 2 > left) { left = '3%'; } } else { left = left + 'px'; } var instance = ownerInstance.selectComponent('.liveMove'); // 返回组件的实例 instance.setStyle({ left: left, top: top +'px', }); } /** * 点击直播入口 * @param */ function clickLive (event, ownerInstance) { ownerInstance.callMethod("bindEnter"); } module.exports = { liveTouchmove: liveTouchmove, clickLive: clickLive, }; [代码] 总结 wxs确实可以解决我们一些性能问题,和wxml函数调用方便,但是我们也要注意几个问题 目前还不支持原生组件的事件、input和textarea组件的 bindinput 事件 1.02.1901170及以后版本的开发者工具上支持交互动画,最低版本基础库是2.4.4 目前在WXS函数里面仅支持console.log方式打日志定位问题,注意连续的重复日志会被过滤掉。 wxs有自己的语法,我理解为不支持ES6及以上语法更多wxs语法可以查看官方文档 以上内容,包括之前写的文章内容,最终会上传到我的gitee里面,请留意。 文章创作不易,喜欢的记得点赞 本文同步掘金号文章 喜欢的记得去点个赞哦
2021-07-11 - ios不支持正则的断言的解决辛酸路程
需求:将文字转为语音并播放,文字内容有电话格式1234-12234343和时间格式9:00-18:30,类似如下字符串:"我们上班时间:9:00-18:30,电话是1234-12234343" 实现方法:用微信的同声传译插件。 遇到问题:同声传译插件会将电话号码的1234读出一千两百三十四,听起来很奇怪有木有?=<=。 解决方法:所以我想着将电话号码之间加上空格,写个正则(/\d/g," $&"),数字是一个一个的读了。 又遇到问题:时间9:00-18:30被分割成了 9: 0 0- 1 8: 3 0,转成语音后就是九零零一八三零,也很奇怪有木有?=<=。 再次解决方法:将时间格式写个正则(/(\d+):(\d+)-(\d+):(\d+)/g,"$1点$2分到$3点$4分"),简直完美,时间格式完美读了出来^-^ 又又遇到问题:处理时间的正则和电话的冲突了呀,那我电话的空格怎么加呢? 再再次解决方法:这个解决方法我研究了1天!终于写出来了,还用上了断言呢~ .replace(/(\d+):(\d+)-(\d+):(\d+)/g,"$1点$2分到$3点$4分").replace(/(?<!到)(?<!点)\d(?!点)(?!到)(?!分)/g, ' $&'),一共花了2天时间搞得正则,终于啊,成功了!迫不及待用手机扫开发版试试,听听那动人的语音结果。 又又又遇到问题:结果……手机打开一片空白,查了下微信社区,555,在ios上不支持,简直晴天霹雳,看下图官方的回复…… [图片] 再再再次解决方法:再次查社区,看到下方正义之光的回答,十分感谢这位卢霄霄同志!! [图片] 我的终极解决方案: /** * @param {传入的原始字符串} str */ dealTextToVoice(str) { // 将时间格式更换为中文 str = str.replace(/(\d+):(\d+)-(\d+):(\d+)/g, "$1点" + "$2分" + "到$3点" + "$4分") let patt = new RegExp("\\d{4}\.", "g") let result // 将3位以上的数字加空格,避免语音读出计数 while ((result = patt.exec(str)) != null) { let newNum = result[0].replace(/\d/g, '$& ') str = str.replace(result[0], newNum) } return str } 至此,完美解决~
2021-11-03 - 通过WXS实现回弹的平滑滚动容器
前言 最近在愉快的开发微信小程序的时候碰到了一个体验需求,需要在 Android 侧的滚动也需要带回弹效果,类似于在 Web 端可以使用的 better-scroll,查阅微信小程序内置组件 [代码]scroll-view[代码] 无法满足这种场景,没办法,需求得做呀,只能自己动手撸了! 在微信小程序中,我们可以通过 WXS响应事件 来替代逻辑层处理从而有效的提高交互流畅度,其中使用到的 WXS语法 也是非常类似我们非常熟悉 JavaScript,不过很多的 JavaScript 高级语法在 WXS 模块中不能使用,具体可以点击链接进入微信小程序提供的文档。 思路 以横向滚动为例,内容的宽度大于容器的宽度时可以发生滚动,如图 [图片] 接着通过监听三个触摸事件[代码]touchstart[代码]、[代码]touchmove[代码]、[代码]touchend[代码]来实时的改变 content 的 CSS translate,从而从视觉上达到滚动的目的。 WXS 示例 我们先从一个简单的 WXS 使用示例来了解回顾一下使用方式,WXS 的模块系统类似 CommomJS 规范,使用每个模块内置的 [代码]module[代码] 对象中的 [代码]exports[代码] 属性进行变量、函数导出: [代码]// helper.wxs module.exports = { // 注意 WXS 模块中不支持函数简写 touchstart: function touchstart() { console.log('touchstart called') } } [代码] [代码]<!-- index.wmxl --> <!-- module 为模块名,可按规范任意取名 --> <wxs src="./helper.wxs" module="helper" /> <!-- 与普通的逻辑层事件不同,这里需要加上 {{}} --> <view bind:touchstart="{{ helper.touchstart }}">view</view> [代码] 这样就给 [代码]view[代码] 绑定了一个 [代码]touchstart[代码] 事件,在事件触发后,会在控制台打印出字符串 "touchstart called" 好了,现在正式进入滚动容器的逻辑实现 开工 新建 [代码]scroll.wxml[代码] 文件,准备符合上图中结构的 WXML 内容来构造出一个正确的可以滚动条件 [代码]<!-- scroll.wxml --> <!-- 即图中的 container --> <view class="container" style="width: 100vw;"> <!-- 即图中的 content --> <view class="content" style="display: inline-block; white-space: nowrap;"> <view wx:for="{{ 10 }}" wx:key="index" style="width: 200rpx; height: 300rpx; border: 1px solid; display: inline-block;">{{ item }}</view> </view> </view> [代码] 新建 [代码]scroll.wxs[代码] 文件,里边用于存放我们实现滚动的所有逻辑 接下来进行初始化操作,首先需要获取到 container 和 content 组件实例,在上一节 “WXS 示例” 中我们知道可以通过在组件中触发一个事件来调用 WXS 模块中的方法,但有没有什么方式可以不用等到用户来触发事件就可以执行吗? 通过阅读 WXS 响应事件 文档,可以了解到,另外一种调用 WXS 模块方法就是可以通过 [代码]change:[prop][代码] 监听某一个组件的 Prop 的改变来执行 WXS 模块中指定的方法,且这个方法会立即执行一次,如下面一个示例 [代码]// helper.wxs module.exports = { setup: function setup() { console.log('setup') } } [代码] [代码]<!-- index.wxml --> <wxs src="./helper.wxs" module="helper"></wxs> <!-- 例如我们指定一个 prop 为 prop1,值为 {{ prop1Data }} --> <!-- 通过 change:prop1 语法对这个 prop 的变化进行监听 --> <view prop1="{{ prop1Data }}" change:prop1="{{ helper.setup }}"></view> [代码] [代码]// index.js Page({ data: { prop1Data: {} } }) [代码] 上面示例中,在页面初始化或 [代码]prop1Data[代码] 发生改变时(准确来说是在逻辑层对 [代码]prop1Data[代码] 调用了 [代码]setData[代码] 方法后,即使 [代码]prop1Data[代码] 的内容不变化),都会调用 [代码]hepler.wxs[代码] 模块中的 setup 方法。 现在我们可以通过 [代码]change:prop[代码] 会立即执行一次的特点,来对我们的滚动逻辑进行一次初始化操作 [代码]// scroll.wxs var exports = module.exports // 页面实例 var ownerInstance // container BoundingClientRect var containerRect // content 实例,通过此实例设置 CSS 属性 var slidingContainerInstance // content BoundingClientRect var slidingContainerRect // X方向的最小、最大滚动距离。如 -200 至 0(手势往右时,元素左移,translateX 为负值) var minTranslateX var maxTranslateX = 0 /** * @param newValue 最新的属性值 * @param oldValue 旧的属性值 * @param ownerInstance 页面所在的实例 * @param instance 触发事件的组件实例 */ exports.setup = function setup(newValue, oldValue, _ownerInstance, instance) { ownerInstance = _ownerInstance containerRect = instance.getBoundingClientRect() slidingContainerInstance = ownerInstance.selectComponent('.content') slidingContainerRect = slidingContainerInstance.getBoundingClientRect() minTranslateX = (slidingContainerRect.width - containerRect.width) * -1 } [代码] [代码]<!-- scroll.wxml --> <wxs src="./scroll.wxs" module="scroll" /> <!-- 因本案例只利用 change:[prop] 首次执行的机制,传递的给 _ 的参数是个对象字面量 --> <view class="container" style="width: 100vw;" _="{{ { k: '' } }}" change:_="{{ scroll.setup }}" bind:touchstart="{{ scroll.touchstart }}" bind:touchmove="{{ scroll.touchmove }}" bind:touchend="{{ scroll.touchend }}" > <view class="content" style="display: inline-block; white-space: nowrap;"> <view wx:for="{{ 10 }}" wx:key="index" style="width: 200rpx; height: 300rpx; border: 1px solid; display: inline-block;">{{ item }}</view> </view> </view> [代码] 完成基本的跟随手指移动 [代码]// scroll.wxs var exports = module.exports // 页面实例 var ownerInstance // container BoundingClientRect var containerRect // content 实例,通过此实例设置 CSS 属性 var slidingContainerInstance // content BoundingClientRect var slidingContainerRect // X方向的最小、最大滚动距离。如 -200 至 0(手势往右时,元素左移,translateX 为负值) var minTranslateX var maxTranslateX = 0 /** * @param newValue 最新的属性值 * @param oldValue 旧的属性值 * @param ownerInstance 页面所在的实例 * @param instance 触发事件的组件实例 */ exports.setup = function setup(newValue, oldValue, _ownerInstance, instance) { ownerInstance = _ownerInstance containerRect = instance.getBoundingClientRect() slidingContainerInstance = ownerInstance.selectComponent('.content') slidingContainerRect = slidingContainerInstance.getBoundingClientRect() minTranslateX = (slidingContainerRect.width - containerRect.width) * -1 } // 实时记录 content 位置 var pos = { x: 0 } // 记录每次触摸事件开始时,content 的位置,后续的移动都是基于此值增加或减少 var startPos = { x: 0 } // 记录触摸开始时,手指的位置,后续需要通过比较此值来计算出移动量 var startTouch = { clientX: 0 } function setTranslate(pos0) { slidingContainerInstance.setStyle({ transform: 'translateX(' + pos0.x + 'px)' }) pos.x = pos0.x } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x } exports.touchmove = function touchmove(event) { var deltaX = event.changedTouches[0].clientX - startTouch.clientX var x = startPos.x + deltaX setTranslate({ x: x }) } exports.touchend = function touchend() {} [代码] 效果图: [图片] 处理松手后移动超出的情况,需要对其归位: 添加 clamp 工具方法 [代码]// 给出最小、最大、当前值,返回一个在最下-最大范围之间的结果 // 如: -100, 0, -101 => -100 function clamp(min, max, val) { return Math.max(min, Math.min(max, val)) } [代码] 在 touchend 事件中,添加位置校验的逻辑 [代码]// scroll.wxs exports.touchend = function touchend() { setTranslate({ x: clamp(minTranslateX, maxTranslateX, pos.x) }) } [代码] 看看效果: [图片] 回去是能回去了,有点生硬~ 加上松手回弹动画 其中动画可以使用两种实现方式 CSS Transition:在松手后,给 content 元素设置一个 [代码]transition[代码],然后调整 [代码]translateX[代码] 值归位 JS 帧动画:在松手后,利用动画函数不断调整 [代码]translateX[代码] 来进行归位 两种方式通过给相同的动画函数可以达到一样的体验,但 CSS Transition 在我的理解中不太好处理中止的情况,如在动画过程中,又有了新的触摸事件,这里就会产生抖动或未预期到的结果,但 JS 动画可以很简单的应对 因此后续的动画部分打算采用 JS 动画实现,先准备一些动画函数 [代码]// scroll.wxs // 下面内容通过 better-scroll 借鉴 ~ // 可以理解为入参是一个 [0, 1] 的值,返回也是一个 [0, 1] 的值,用来表示进度 var timings = { v1: function (t) { return 1 + --t * t * t * t * t }, v2: function(t) { return t * (2 - t) }, v3: function(t) { return 1 - --t * t * t * t } } [代码] 定义 [代码]moveFromTo[代码] 方法来实现从一个点通过指定的动画函数运动到另一点 [代码]// scroll.wxs /** * @param fromX 起始点xx * @param toX 目标点 x * @param duration 持续时长 * @param timing 动画函数 */ function moveFromTo(fromX, toX, duration, timing) { if (duration === 0) { setTranslate({ x: fromX }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) } } ownerInstance.requestAnimationFrame(rAFHandler) } } [代码] 调整 touchend 事件处理逻辑,添加归位的动画效果 [代码]// scroll.wxs exports.touchend = function touchend() { moveFromTo( pos.x, clamp(minTranslateX, maxTranslateX, pos.x), 800, timings.v1 ) } [代码] 看看效果: [图片] 看起来达到了目的,再优化一下,在滑动超出边界后,需要给一些阻力,不能滑的“太简单了” 给超边界的滚动加阻力 [代码]// scroll.wxs exports.touchmove = function touchmove(event) { var deltaX = event.changedTouches[0].clientX - startTouch.clientX var x = startPos.x + deltaX // 阻尼因子 var damping = 0.3 if (x > maxTranslateX) { // 手指右滑导致元素左侧超出,超出部分添加阻尼行为 x = maxTranslateX + damping * (x - maxTranslateX) } else if (x < minTranslateX) { // 手指左滑导致元素右侧超出,超出部分添加阻尼行为 x = minTranslateX + damping * (x - minTranslateX) } setTranslate({ x: x }) } [代码] 瞅瞅: [图片] 效果达到了,手指都划出屏幕了,才移动了这么一点距离 到现在已经完成了一个带回弹效果的滚动容器,但还没有做到“平滑”,即在滑动一段距离松手后,需要给 content 一些“惯性”来继续移动一些距离,体验起来就不会那么生硬 加滑动惯性 在这之前,还有一些准备工作需要做 [代码]// scroll.wxs // 记录触摸开始的时间戳 + var startTimeStamp = 0 // 增加动画完成回调 + function moveFromTo(fromX, toX, duration, timing, onComplete) { if (duration === 0) { setTranslate({ x: fromX }) + ownerInstance.requestAnimationFrame(function() { + onComplete && onComplete() + }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) + } else { + onComplete && onComplete() + } } ownerInstance.requestAnimationFrame(rAFHandler) } } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x + startTimeStamp = event.timeStamp } [代码] 因为是在松手后加动量,所以继续处理 touchend [代码]// scroll.wxs exports.touchend = function touchend(event) { // 记录这一轮触摸动作持续的时间 var eventDuration = event.timeStamp - startTimeStamp var finalPos = { x: pos.x } var duration = 0 var timing = timings.v1 var deceleration = 0.0015 // 计算动量,以下计算方式“借鉴”于 better-scroll,有知道使用什么公式的朋友告知以下~ var calculateMomentum = function calculateMomentum(start, end) { var distance = Math.abs(start - end) var speed = distance / eventDuration var dir = end - start > 0 ? 1 : -1 var duration = Math.min(1800, (speed * 2) / deceleration) var delta = Math.pow(speed, 2) / deceleration * dir return { duration: duration, delta: delta } } // 此次滑动目的地还在边界中,可以进行动量动画 if (finalPos.x === clamp(minTranslateX, maxTranslateX, finalPos.x)) { var result = calculateMomentum(startPos.x, pos.x) duration = result.duration finalPos.x += result.delta // 加上动量后,超出了边界,加速运动到目的地,然后触发回弹效果 if (finalPos.x > maxTranslateX || finalPos.x < minTranslateX) { duration = 400 timing = timings.v2 var beyondDis = containerRect.width / 6 if (finalPos.x > maxTranslateX) { finalPos.x = maxTranslateX + beyondDis } else { finalPos.x = minTranslateX + beyondDis * -1 } } } moveFromTo(pos.x, finalPos.x, duration, timing, function () { // 若动量动画导致超出了边界,需要进行位置修正,也就是回弹动画 var correctedPos = { x: clamp(minTranslateX, maxTranslateX, pos.x) } if (correctedPos.x !== pos.x) { moveFromTo( pos.x, correctedPos.x, 800, timings.v1 ) } }) } [代码] 继续看看效果: [图片] 有了有了 只是现在的滚动容器还很“脆弱”,在进行动量动画、回弹动画时,如果手指继续开始一轮新的触摸,就会出现问题,也就是最开始我们在选择 CSS 过渡和 JS 动画考虑到的问题 解决连续触摸滑动问题 在 [代码]moveFromTo[代码] 方法中,添加强制中止的逻辑 [代码]// scroll.wxs + var effect = null function moveFromTo(fromX, toX, duration, timing, onComplete) { + var aborted = false if (duration === 0) { setTranslate({ x: fromX }) ownerInstance.requestAnimationFrame(function () { onComplete && onComplete() }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { + if (aborted) return var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) } else { onComplete && onComplete() } } ownerInstance.requestAnimationFrame(rAFHandler) } + if (effect) effect() + effect = function abort() { + if (!aborted) aborted = true + } } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x startTimeStamp = event.timeStamp + if (effect) { + effect() + effect = null + } } [代码] 体验一下: [图片] 这样一个带回弹的平滑滚动容器就处理的可以使用啦,有问题的地方欢迎大家指出讨论 结尾 完整源码托管在 Github 中:weapp-scroll 其中功能、逻辑更为完善,并同时支持横向、竖向方向的滚动,适合在 Android、PC 场景的使用(毕竟 IOS 侧可以直接使用微信内置组件 [代码]scroll-view[代码]~)。若有帮到希望可以给个星星~ 完~
2023-07-07 - 小程序悬浮按钮,悬浮导航球
[图片] 一个开源的悬浮按钮组件,小程序原生支持。 一直很喜欢华为的导航按钮,能够完美适合大屏手机,自由停放位置,不论是左手习惯还是右手习惯,都很方便(可能我手比较小,左右上角够不着)。 支持功能 支持自由拖动,停放 支持自定义事件(单击,双击,长按) 支持自定义导航球中间的文字/图片 开发难点 使用wxs 悬浮球的开发思路比较简单,一个view,样式[代码]position:fixed[代码],支持拖动。在web开发中,我们能够比较容易实现这样的功能。要想在小程序中实现高性能的交互动画(touch类),一定要了解如何使用页面的[代码]wxs[代码]这个残疾JS来操作对象(调试很麻烦,js极度残疾) [代码]<wxs module="tool"> function tStart(e, ins){} function tMove(e, ins){ e.instance.setStyle('transform: translate3d(...)') // e.instance指向当前操作对象 // setStyle 设置该对象的style样式 } function tEnd(e, ins){} module.exports = { tStart: tStart, tMove: tMove, tEnd: tEnd } </wxs> <view catch:touchstart="{{tool.tStart}}" catch:touchmove="{{tool.tMove}}" ... /> [代码] 这里使用catch,而不是使用bind来绑定事件,事件指向[代码]wxs[代码]的方法。考虑到悬浮导航球是作为工具在其他场景中使用,为了不会污染touch事件,或者导致页面不必要的滚动。 位移距离 手机宽高不一致,即x轴的运动距离小于y轴运动距离(单位时间),假定手机宽高比为1:2,x轴运动1px,y轴则运动了2px,我们可以设置一定的系数,使得拖动效果符合预期。 监听事件 最终的事件响应一定是在page页面(或者组件内部)实现事件监听,wxs有一套事件调用机制 [代码]function tStart(e, ins){ ins.callMethod('onTouchStart', e) // 调用当前组件/页面在逻辑层(App Service)定义的函数。funcName表示函数名称,args表示函数的参数 } [代码] wxs相关文档 GITHUB开源 DEMO及文档关注小程序 [图片]
2020-03-06 - 小程序端会话场景下长列表实现
1 前言 腾讯云医小程序中有医生和患者聊天的场景,在处理该场景的列表过程中遇到两个问题: 一是下拉加载历史消息时需要在容器顶部进行衔接导致的界面抖动问题;二是大量的会话内容导致的长列表问题。 问题一:插入历史消息带来的抖动问题是因为在已有dom的前面插入dom。如果能够在已有dom的后面插入新增dom并且在视觉上看起来是在顶部插入的则可以解决该问题。前端开发中聊天场景的体验优化一文中给出的方案是[代码]transform:rotate(180deg);[代码]。另外[代码]flex-direction:reverse[代码]也是可以做到的。 由于会话场景的一些其他特点如列表初始化时定位在底部(新消息在底部),本文的实现采用了[代码]transform:rotateX(180deg)[代码]方式处理进行处理。由于只需要在垂直方向进行翻转,所以在实现时使用rotateX代替了rotate。 下面简易demo说明该样式应用后的效果 [代码] .container { height: 100px; overflow: auto; } .item { width: 100px; border: 1px solid black; text-align: center; } /*关键*/ .x_reverse { transform: rotateX(180deg); } [代码] [代码]<div class="container x_reverse"> <div id="item-1" class="x_reverse item">数据项-1</div> ... <div id="item-9" class="x_reverse item">数据项-9</div> </div> [代码] 添加[代码].x-reverse[代码]样式前后的初始状态对比 翻转前 [图片] 翻转后 [图片] 问题二: 长列表问题。 我们先在h5端看下大量的dom会有哪些问题,如下demo验证 [代码]<button id="button">button</button><br> <ul id="container"></ul> [代码] [代码]document.getElementById('button').addEventListener('click',function(){ let now = Date.now(); const total = 10000; let ul = document.getElementById('container'); for (let i = 0; i < total; i++) { let li = document.createElement('li'); li.innerText = Math.random() ul.appendChild(li); } }) [代码] 在chrome的开发者工具performance栏下记录点击button后的运行过程,可以看到包含脚本运行在内的整个运行过程中 Rendering部分占用时间最多(包含[代码]Recalculate Style[代码]、[代码]Layout[代码]、[代码]Update Layer Tree[代码])。当列表项数越多并且列表项结构越复杂的时候,会在Recalculate Style和Layout阶段消耗大量的时间,所以有必要减少列表项的同时渲染。 [图片] 小程序的架构决定着小程序端该问题相较于h5端更为突出。在微信小程序官方文档 -> 指南 -> 性能与体验部分提到一些点如:setData数据大小、WXML节点数等原因都会影响到小程序的性能。以及图片资源的主要性能问题在于大图片和长列表图片上,这两种情况都有可能导致 iOS 客户端内存占用上升,从而触发系统回收小程序页面。显然在长列表场景下如果一次性将所有的数据全部加载出来就会有WXML节点过多,setData数据量过大的问题、图片资源过度等问题。 这些问题不仅仅是列表在初始化的时候存在,如在插入新数据(unshift)需要将整个数组进行传递,以及更新列表项数据时diff时间也会增大。 微信小程序官方提供了recycle-view组件来解决等高列表项的情况。但是对于会话场景下消息的高度是不等的,因此我们得自己实现一套符合这种特性的长列表组件。 2 接入前后对比 2.1 视频效果对比 对比腾讯云医小程序会话接入长列表组件前后的效果,优化前滚动过程中有卡顿的感觉,并且在发送消息的时候,消息输入框进入到列表中的延迟能够比较明显的感受到,优化后滚动较丝滑,并且发送消息没有明显的延迟。 接入前:https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/after-chat.mp4" 接入后:https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/before-chat.mp4" 对比腾讯云医小程序->群发助手下的患者列表初始化和选中时接入长列表组件前后的对比 接入前 :https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/group-send-before.mp4 接入后:https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/group-send-after.mp4" 2.2 数据对比 这里对比下群发助手接入前后的setData(发起到回到)时间的对比 初始化用时对比 [图片] 选中item用时对比 [图片] 上面两张图的横坐标是数据条数,纵坐标是setData时间,可以看到无论是初始化还是选中操作二者的轨迹都是相似的 明显的看到接入前,setData的时间随着数据量的增大越来越大,接入后则没有这个问题。显然,接入后通信的数据量,diff时间,浏览器渲染时间都会较少。 3 基础实现 关于长列表实现的基本思路是只渲染可视区域及其附近的几屏数据,但是由于小程序端和h5端架构的差异导致二者在具体实现上存在差异。 3.1 如何模拟滚动条? h5实现长列表的常规思路 [代码]<div id="list-container"> <div id="list-phantom"></div> <div id="list"> <!-- item-1 --> <!-- ... --> <!-- item-n --> </div> </div> [代码] #list-container 滚动容器 通过引入#list-phantom来占位,高度为列表项高度之和用于撑开容器形成滚动条 #list用来装载列表数据 当有新的列表项添加后,则更新#list-phantom高度从而达到模拟滚动条的目的,然后通过监听#list-container的scroll事件在其回调中根据scrollTop来计算出现在可视区域的内容。 浏览器是多进程多线程架构,浏览器中打开一个tab页时可以认为是打开一个渲染进程,渲染进程中包含了GUI渲染线程(包括了html、css解析,dom树构建等工作)和js引擎线程等等。我们知道GUI渲染线程和JS引擎线程是互斥的,js引擎发起界面更新到渲染完成是同步的。 而小程序架构的通信是异步的,比如逻辑层setData发起通信到渲染层,通信过程中渲染层依然在执行的。如果按照h5的思路去计算,逻辑层计算的结果到达渲染层后就已经不是正确的结果了即界面中的数据和滚动条的位置是对不上的。 为了保证滚动条的位置和数据项所在位置是正确对应,起初的想法是列表项消失后通过一个同等高度的div元素进行代替,这样做带来的问题是依然会产生大量的dom元素。进一步的想法是通过对列表数据进行分组并且每个分组在界面中会存在一个真实的dom(称为分组dom)来包裹该分组内的所有列表项,并且认为每个分组算是一屏数据,当每个分组从界面中消失时,分组dom不会删除,只会删除内部的列表项,并且将消失的列表项高度之和赋值给该分组dom。这样解决了滚动条高度的问题,并且不需要计算具体哪些列表项数据需要被加载出来,只需要知道加载哪(些)个分组即可。 分组的想法既简化了计算又保证了数组项和滚动条的位置是正确对应的。 高度的获取和赋值是在wxs里面做的,由于wxs是在渲染层执行的,相比在逻辑层减少了通信的成本。 下面给出简易(伪)代码来描述这段过程 视图层 [代码]<scroll-view clearingids="{{clearingGroupIds}}" renderingids="{{renderedGroupIds}}" change:clearingids="{{module.clearingHandle}}" change:renderingids="{{module.renderingHandle}}" class="list-wrapper x_reverse"> <!-- 分组dom --> <view class="piece-container" wx:for="groups" wx:for-item="group" id="piece-container-{{group.id}}"> <view class="x_reverse" wx:for="group.data"> {{item.content}} </view> </view> </scroll-view> [代码] 逻辑层 [代码]// 分组数据结构(二维数组) groups:[ { id: 1, // 分组id data:[{ content:{a:'a'} },...] } ,...], // 当前需要渲染的分组 renderingids:[], // 需要移除的分组 clearingGroupIds:[] [代码] wxs 更新分组dom高度,用法参考官方文档WXS响应事件 [代码]module.exports = { clearingHandle:function(clearingGroupIds, oldV, ownerInstance){ clearingGroupIds.forEach(function(groupId){ // 1. 根据 groupId 找到对应的分组dom // 2. 获取分组dom高度 // 3. 设置分组dom样式:height }) }, renderingHandle: function (renderingGroupIds, oldGroup, ownerInstance) { renderingGroupIds.forEach(function(groupId){ // 1. 根据 groupId 找到对应的分组dom // 2. 移除height样式 }) } } [代码] 3.2 如何知道渲染哪些数据 当有新的数据需要渲染到列表中时,首先是对数据进行分组,然后通过小程序提供的IntersectionObserver能力对分组dom进行监听,在其回调中判断该dom是否进入scroll-vew从而来更新正在渲染的分组和需要移除的分组。 [代码] // 滚动容器domId const containerId = '#scroll-container-xxx' // 创建监听 _createObserver(groupIds = []) { groupIds.forEach(groupId => { const observer = wx.createIntersectionObserver(this).relativeTo(containerId); observer.observe(domId, this._observerCallback); }) } // 监听回调 _observerCallback(result) { // 1. 根据result拿到domId然后解析拿到groupId(domId包含了groupId信息 // 2. 判断当前分组是否在视口内,如果不在视口内直接返回 // 3. 如果分组在视口内,则计算需要渲染的分组ids和需要移除的分组ids // 4. 通信至视图层,渲染目标分组数据和移除失效的分组数据( // 4.1 移除的优先级不高,不应该阻塞渲染目标分组,因此可以通过debounce/throttle处理) // 4.2 短时间内多次setData会导致通信通道阻塞,比如可以将setData放在队列中处理,一个一个来(中间可能有些失效则可以跳过 } [代码] 总结:基于2.1和2.2已经可以完成基本的雏形,另外有些其他的点需要优化 4 优化 4.1 unshift带来的问题 在小程序中通常将列表数据存储到数组中,由于小程序setData的数据量越小越好,更新数组时通常不会将整个数组对象进行setData,而只是更新数组对象的某个属性,如下: [代码]// 在数组尾部插入数据时 小程序支持下面方式 this.setData({ [array[array.length]]: newObj }) // 更新数组中某项的属性时 this.setData({ [array[0].a]: 'a' }) [代码] 如果要向数组顶部插入数据,做不到只传递新增的数据 [代码]array.unshift({}) this.setData({array}) // => 缺点是 逻辑层到渲染层会传递整个数组对象 [代码] 本文的背景是要解决会话场景下的长列表问题,对于会话即存在插入历史消息的场景,又存在插入新消息的场景,相当于我们数组两端都需要有插入数据的能力。需要对数据进行push/unshift操作。但是前面提到unshift效果不好。因此本文通过两个数组,一个数组存放历史消息,一个数组存放新消息,并在dom结构上也增加了对应的结构。 dom结构如下 [代码]<scroll-view class="x_reverse"> <view class="next-item-wrapper"> <!--多了一层--> <view class="x_reverse"> <!--新消息区域--> <view wx:for=“new-groups” wx:for-item="group"> <view wx:for="group.data"> {{item.content}} </view> </view> </view> </view> <view class="history-item-wrapper"> <!--历史消息区域--> <view wx:for=“his-groups” wx:for-item="group"> <view class="x_reverse" wx:for="group.data"> {{item.content}} </view> </view> </view> </scroll-view> [代码] 区域定义: 历史消息区域:初始化的消息以及插入的历史消息 新消息区域:列表初始化完成之后新来的消息 制定了如下规则 分组id越大表示分组的消息越久远,分组id越小表示分组的消息越新 历史分组id从1开始递增,新消息区域分组id从0开始递减 新消息区域自身未做任何的翻转,就像正常的列表一样,有新的消息或者新的分组push就行 历史消息区域的分组受到翻转的影响,在历史消息分组中push新的消息或者新的分组表现为插入历史消息 其原理如下图 [图片] 与上面dom结构对应的数据结构如下 [代码]class fuse { constructor() { // 存储历史消息 this.histGroups = []; // groupId >= 1 // 存储新消息 this.newGroups = []; // groupId <= 0 } // 插入新消息 push(listGroups){ this.newGroups.push(...listGroups) } // 插入历史消息 unshift(listGroups){ this.histGroups.push(...listGroups) } } [代码] 4.2 白屏问题 4.2.1 白屏现象的解释 滚动过程中长列表组件会进行setData操作以更新视口区域的数据,在快速滚动的情况下,假设此时逻辑层的计算结果是需要渲染第3屏幕的数据,但是由于从逻辑层通信到视图层是需要时间,这段时间中第三屏的界面可能已经滚动到视口外,此时的渲染是无效的,用户看到的可能已经是第8屏的数据,但是这个时间点第8屏幕的数据并没有渲染,这就会导致白屏现象的出现。 如果我们能根据屏幕滚动的速率和通信的时间去预测下一帧哪一屏出现在视口区域,那么就可以避免白屏问题。显然这是个难题,因为你不知道用户什么时候会调整滚动的速度,并且setData的时间也受限于很多因素。因此小程序架构下长列表组件带来的白屏问题是无解的。但可以通过预加载上下几屏的数据等一些其他优化方案降低白屏出现的几率以及给出一些骨架效果来缓解用户的焦虑。 4.2.2 骨架效果模拟 由于WXML节点过多也会影响长列表性能,因此否定了渲染真实dom来实现骨架,目前是通过图片作为背景通过在垂直方向平铺的方式来模拟骨架效果。 这种方式对于列表项是等高的场景是完美的解决方案,对于列表项非等高的场景可能会看到背景有被’截断‘情况。不过实际体验来看在快速滚动的情况下,这种’截断‘被看到的概率是偏低的,从实际效果来看是可以接受的。 等高列表项(患者列表 ):https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john//video-1.mp4 非等高列表项(会话 ):https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john//video-2.mp4 4.3 图片高度异步确定带来的麻烦 加载图片资源需要经过网络,属于异步加载,因此img标签的高度的确定也是异步的。假设一种场景,当前分组中的图片资源尚未加载完成,由于滚动的发生需要将该分组中的列表项移除,显然这个时候给分组dom设置的高度是不准确的,当下一次重新渲染该分组时,图片重新加载到完成后,该分组的高度会发生生变化,此时会发生界面的跳动,该如何处理呢? 通过添加滚动锚定特性处理。滚动锚定是指当前视区上面的内容突然出现的时候,浏览器自动改变滚动高度,让视区窗口区域内容固定,就像滚动效果被锚定一样。因此通过设置滚动锚定特性可以解决界面跳动的问题 也可以通过动画的过渡效果来缓解跳动现象,这依赖于height相关的样式属性,因此需要给分组dom设置相关的样式值。 可以显示的给分组dom设置height样式:比如可以在图片加载完成后通知长列表组件去更新分组dom的高度,当高度设置了css3过渡动画,就会以动画形式展开。 也可以通过给分组dom设置min-height/max-height代替height,并给min-height/max-height设置css3动画。上面使用height方式存在一个问题,分组的高度只有在增高的前提下才会被感知,没有降低的可能性;而通过min-height/max-height组合(min-height:0,max-height:height + 1000px),分组高度的增加和降低都会被感知到 本文的实现是:滚动锚定 + min-height/max-height 下面是更新min-height/max-height的核心代码,通过监听 renderingids & clearingids属性的变化,在change回到中处理相关逻辑。 [代码]<scroll-view clearingids="{{clearingGroupIds}}" renderingids="{{renderedGroupIds}}" change:clearingids="{{chat.clearingHandle}}" change:renderingids="{{chat.renderingHandle}}" /> [代码] wxs [代码]// 分组消失时 设置mix-height/max-height = 实际高度 clearingHandle: function (clearingGroupIds, oldV, ownerInstance) { clearingGroupIds.forEach(function (groupId) { // 获取分组dom var pieceContainer = ownerInstance.selectComponent('#piece-container-' + groupId) var res = pieceContainer.getComputedStyle(['height']) pieceContainer.setStyle({ 'min-height': res.height, 'max-height': res.height }) }) // 分组重新渲染时 // min-height设置为0,实际的高度由分组中的列表项撑开 renderingHandle: function(renderingGroupIds, oldV, ownerInstance) { renderingGroupIds.forEach(function (groupId) { // 获取分组dom var pieceContainer = ownerInstance.selectComponent('#piece-container-' + groupId) var res = pieceContainer.getComputedStyle(['height']) // 高度大于一瓶 足够视口区域的内容发挥了 var maxHeight = parseInt(res.height) + 1000 + 'px' pieceContainer.setStyle({ 'min-height': '0' }) pieceContainer.setStyle({ 'max-height': maxHeight }) }) } [代码] 事实上最完美的方式是在上传图片的时候记录图片的宽高比例等信息,在渲染时计算好img标签高度,而不是依赖图片的加载结果,这样可以保证img标签高度是同步确定的。退一步的做法是可以在图片第一次加载完成后缓存宽高,再次渲染的时候显示的设置img标签宽高。 5 其他 5.1 由于翻转带来的其他副作用 ios下transform:rotate会导致z-index无效 Safari 3D transform变换z-index层级渲染异常的研究–张鑫旭。在Safari浏览器下,此Safari浏览器包括iOS的Safari,iPhone上的微信浏览器,以及Mac OS X系统的Safari浏览器,当我们使用3D transform变换的时候,如果祖先元素没有overflow:hidden/scroll/auto等限制,则会直接忽略自身和其他元素的z-index层叠顺序设置,而直接使用真实世界的3D视角进行渲染。 scroll-into-view无效问题 该问题在另一篇文章中说到过并且给出了解决方案。 小程序scroll-view翻转后 scroll-into-view的替代方案 5.2 根据groupNums计算待渲染/移除的分组id 本文实现的长列表组件提供了groupNums属性,该属性用来指定每个分组包含多个列表项。上文说到我们在IntersectionObserver监听的回调中来计算需要渲染的下一屏分组id。 如果长列表组件不存在删除元素的操作,那么假设当前进入视口的分组id是x,并且总是额外显示上一屏和下一屏的分组。那么当x是边缘分组时,目标分组就是[x,x+1] 或 [x-1,x];当x不是边缘分组的情况,目标分组是[x-1, x, x+1] 由于本文实现的长列表组件提供了删除中间列表项的方法,假设x,x-1,x+1这三个分组都被删除只剩下1一个列表项,那么按照上述计算方式计算返回的分组渲染出来后实际上可能还不够一屏。这个时候我们需要利用groupNums这个指标进行计算,比如当分组在中间时,得确保有3 * groupNums个列表项被渲染出来。 5.3 scroll-view底部回弹区域setData时跳动问题 问题:滑动页面到底部,使其出现橡皮筋效果,处于橡皮筋效果时SetData数据,会使页面跳动一下,处于橡皮筋效果时SetData会使页面跳动闪屏 解决方案:关闭橡皮筋效果即可 示例代码: [代码]<scroll-view enhanced="{{true}}" bounces="{{false}}" /> [代码] 5.4 一条消息的布局 问题:当滚动区域只有少数列表项,这些列表项高度之和小于滚动容器高度时,由于对滚动容器应用了翻转样式,此时列表项会布局在底部(应该在顶部) 解决方案:通过包裹在一个div内,应用如下样式解决 示例代码: [代码]<scroll-view class="x_reverse"> <view class="all-container"> <view class="next-item-wrapper">...</view> <view class="history-item-wrapper">...</view> </view> </scroll-view> [代码] [代码].all-container { display: flex; flex-direction: column; justify-content: flex-end; height: auto; min-height: 100%; } [代码] 5.5 自动弹出加载更多组件 问题:以加载历史消息为例,当消息滚动到顶部下拉开始加载历史消息时,如果只是设置showLoadMore为true,视觉上会看不到loadmore组件(原因是scroll-view设置了滚动锚定),需要再次向下拉一次,才能把该组件拉入到视区内。显然这样的体验不够好,如果拉到顶部开始加载历史消息时,该组件自动出现在用户的视觉内效果会好些。 示例代码(old): [代码]<scroll-view class="x_reverse"> <view class="all-container"> <view class="next-item-wrapper">...</view> <view class="history-item-wrapper">...</view> </view> <view class="x_reverse"> <load-more wx-if={{showLoadMore}}/> </view> </scroll-view> [代码] 解决方案:通过两个变量loadingDone&loading来维护该组件,loading为true时显示上面的组件,loadingDone为true时显示内部的组件 示例代码(new): [代码]<block> <!--正在加载,显示这里--> <load-more wx-if={{loading}}/> <scroll-view class="x_reverse"> <view class="all-container"> <view class="next-item-wrapper">...</view> <view class="history-item-wrapper">...</view> </view> <view class="x_reverse"> <!--没有更多数据了,显示这里--> <load-more wx-if={{loadingDone}}/> </view> </scroll-view> </block> [代码] 5.6 计算reccordIndex 在不删除中间列表项的情况下,传递的recordIndex是准确的,通过数学关系在wxs中实时进行计算 [代码]<list-item recordIndex="{{chat.calculateIndex(group, groupNums, index, renderedHistorySum)}}" /> [代码] wxs [代码]// index 当前列表项在当前分组的索引 // groupNums 单个分组列表项数 // renderedHistoryGroups是历史区域的列表项数 // group 用于获取groupId calculateIndex: function (group, groupNums, index, renderedHistorySum) { if (group.id > 0) { // 历史区域 return renderedHistorySum - ((group.id - 1) * groupNums + index) - 1 } return renderedHistorySum + (-group.id) * groupNums + index } [代码] [代码]observers: { 'renderedHistoryGroups.**'() { let renderedHistorySum = 0; const { renderedHistoryGroups, groupNums } = this.data; if (renderedHistoryGroups.length) { const { data: endGroupData } = getEndElement(renderedHistoryGroups); renderedHistorySum = (renderedHistoryGroups.length - 1) * groupNums + endGroupData.length; } this._setDataWrapper({ renderedHistorySum }); }, }, [代码] 5.7 抽象节点 列表项组件是通过抽象节点注入给长列表组件的 6 总结 下面是基于文中所述实现的目录,所有逻辑层代码放在behavior中以共享,normal-scroll针对普通场景的长列表,而chat-scroll针对会话场景的长列表。 [图片]
2022-02-17 - 微信小程序UI组件库合集
UI组件库合集,大家有遇到好的组件库,欢迎留言评论然后加入到文档里。 第一款: 官方WeUI组件库,地址 https://developers.weixin.qq.com/miniprogram/dev/extended/weui/ 预览码: [图片] 第二款: ColorUI:地址 https://github.com/weilanwl/ColorUI 预览码: [图片] 第三款: vantUI(又名:ZanUI):地址 https://youzan.github.io/vant-weapp/#/intro 预览码: [图片] 第四款: MinUI: 地址 https://meili.github.io/min/docs/minui/index.html 预览码: [图片] 第五款: iview-weapp:地址 https://weapp.iviewui.com/docs/guide/start 预览码: [图片] 第六款: WXRUI:暂无地址 预览码: [图片] 第七款: WuxUI:地址https://www.wuxui.com/#/introduce 预览码: [图片] 第八款: WussUI:地址 https://phonycode.github.io/wuss-weapp/quickstart.html 预览码: [图片] 第九款: TouchUI:地址 https://github.com/uileader/touchwx 预览码: [图片] 第十款: Hello UniApp: 地址 https://m3w.cn/uniapp 预览码: [图片] 第十一款: TaroUI:地址 https://taro-ui.jd.com/#/docs/introduction 预览码: [图片] 第十二款: Thor UI: 地址 https://thorui.cn/doc/ 预览码: [图片] 第十三款: GUI:https://github.com/Gensp/GUI 预览码: [图片] 第十四款: QyUI:暂无地址 预览码: [图片] 第十五款: WxaUI:暂无地址 预览码: [图片] 第十六款: kaiUI: github地址 https://github.com/Chaunjie/kai-ui 组件库文档:https://chaunjie.github.io/kui/dist/#/start 预览码: [图片] 第十七款: YsUI:暂无地址 预览码: [图片] 第十八款: BeeUI:git地址 http://ued.local.17173.com/gitlab/wxc/beeui.git 预览码: [图片] 第十九款: AntUI: 暂无地址 预览码: [图片] 第二十款: BleuUI:暂无地址 预览码: [图片] 第二十一款: uniydUI:暂无地址 预览码: [图片] 第二十二款: RovingUI:暂无地址 预览码: [图片] 第二十三款: DojayUI:暂无地址 预览码: [图片] 第二十四款: SkyUI:暂无地址 预览码: [图片] 第二十五款: YuUI:暂无地址 预览码: [图片] 第二十六款: wePyUI:暂无地址 预览码: [图片] 第二十七款: WXDUI:暂无地址 预览码: [图片] 第二十八款: XviewUI:暂无地址 预览码: [图片] 第二十九款: MinaUI:暂无地址 预览码: [图片] 第三十款: InyUI:暂无地址 预览码: [图片] 第三十一款: easyUI:地址 https://github.com/qq865738120/easyUI 预览码: [图片] 第三十二款 Kbone-UI: 地址 https://wechat-miniprogram.github.io/kboneui/ui/#/ 暂无预览码 第三十三款 VtuUi: 地址 https://github.com/jisida/VtuWeapp 预览码: [图片] 第三十四款 Lin-UI 地址:http://doc.mini.talelin.com/ 预览码: [图片] 第三十五款 GraceUI 地址: http://grace.hcoder.net/ 这个是收费的哦~ 预览码: [图片] 第三十六款 anna-remax-ui npm:https://www.npmjs.com/package/anna-remax-ui/v/1.0.12 anna-remax-ui 地址: https://annasearl.github.io/anna-remax-ui/components/general/button 预览码 [图片] 第三十七款 Olympus UI 地址:暂无 网易严选出品。 预览码 [图片] 第三十八款 AiYunXiaoUI 地址暂无 预览码 [图片] 第三十九款 visionUI npm:https://www.npmjs.com/package/vision-ui 预览码: [图片] 第四十款 AnimaUI(灵动UI) 地址:https://github.com/AnimaUI/wechat-miniprogram 预览码: [图片] 第四十一款 uView 地址:http://uviewui.com/components/quickstart.html 预览码: [图片] 第四十二款 firstUI 地址:https://www.firstui.cn/ 预览码: [图片]
2023-01-10 - 微信开发者工具无法自动编译了?
1、昨天开始微信开发者工具无法登录 2、卸载重新安装后,无法自动编译了,手动编译也不好使 3、设置中已勾选保存文件自动编译 但就是不好使 4、先后下载了稳定版、开发版,都不好使 请问是什么问题?能尽快修复吗 没法工作了
2020-09-14 - 使用分包异步化组件实现可变Tab页面
背景和需求 众所周知,在微信小程序内,TabBar 页面必须放主包内,这固然是为了用户体验做出的限制,但是也限制了开发者,如果想要实现不同的客户可以定制不同的TabBar页面,而很多页面又是分散到不同分包内的,那我们能选择的方案也就是在所有可作为TabBar页面上放置自定义TabBar组件,而后根据客户的不同配置,展示不同的TabBar 选项,当客户点击Tab时,使用[代码]navigateTo[代码]或[代码]redirectTo[代码]进行切换页面。 但这个方案存在明显的问题,首先如果使用[代码]navigateTo[代码]进行切换,会有很明显的页面切换动画,很容易到达10层页面栈限制(当然这个可以使用无限路由方案进行缓解,但是无限路由是一种万不得已且体验很差的路由方案),且由于页面未进行销毁,内存占用会比较大,容易造成卡顿;如果使用[代码]redirectTo[代码]进行切换,页面节点状态无法保存(如滚动位置),页面数据倒是可以使用全局状态管理库进行保存,但是每次在切换 Tab 都会有明显的数据重新加载的动画效果。 曙光 在微信小程序支持分包异步化之前,对于上面的问题一直没有好的解决方案,支持分包异步化之后,我们可以将一些组件放入分包内异步加载,这一定程度上解决了主包过大的问题。同时也让我们看到了希望,我们可以将很多组件放入分包内进行异步加载,主包空间空了出来,可以放更多的页面,但不是所有页面都能放入主包,那还有其他方案吗? 我们想,既然组件能从分包异步加载,那页面可以吗? 我们知道,在微信小程序内,通常都会使用Page进行声明页面,但也可以用Component声明页面,也就是说 Component 声明的组件可以当成页面用,那反过来,Page 声明的页面可以当成组件用吗? 答案是可以,但是当这样使用的时候,页面的生命周期方法不会被执行,且实例对象上不存在options(页面路由参数),route(当前页面路由地址)等数据,那我们就不能愉快地玩耍了吗? No! 没有页面该有的属性?那我们就拿到实例对象给他补上去! 生命周期方法不执行?那我们就拿到实例对象后自己去调用! 解决思路 要将现有页面作为组件加载,那我们必须要有一个容器页面,去承载真实页面,在容器页面中去补上已经作为组件的真实页面缺失的属性,在对应的生命周期方法中调用真实页面的生命周期钩子。 我们第一步就需要创建一个容器页面出来,我们可以选择手动创建,也可以自动化创建, 但是已有项目来说,手动创建太费时,且每增加新页面都要修改容器页面代码,故此不考虑。 自动化构建容器页面包含如下步骤: 读取 app.json,获取所有分包页面路径 读取分包页面对应的json文件,将其中内容记录到 [代码]tab-bar-page-config.js[代码] 中,因为我们需要在运行时读取真实页面的标题,背景色等信息,而微信小程序不支持从js中读取json文件,故需要将json内容提前读取出来,为了减少数据量,记录时可以将[代码]usingComponents[代码]等无需运行时使用的数据去掉。效果如下: [代码]// tab-bar-page-config.js module.exports = { "/pack_a/page_1": { "navigationBarTitleText": "页面标题", "navigationBarBackgroundColor": "#ffffff" }, "/pack_b/page_2": { "navigationBarTitleText": "页面标题", "navigationBarBackgroundColor": "#ffffff" } /* 其他页面信息 */ } [代码] 生成 wxml 文件,效果如下: [代码]<pack_a_page_1 id="pack_a_page_1" wx:if="{{ pagePath === '/pack_a/page_1' }}" /> <pack_b_page_2 id="pack_b_page_2" wx:elif="{{ pagePath === '/pack_b/page_2' }}" /> <!-- 其他页面节点 --> [代码] 生成容器页面 json 文件,效果如下: [代码]{ "usingComponents": { "pack_a_page_1": "/pack_a/page_1", "pack_b_page_2": "/pack_b/page_2", /* 其他页面 */ }, "componentPlaceholder": { "pack_a_page_1": "view", "pack_b_page_2": "view", /* 其他页面 */ } } [代码] 编写容器页面 js 逻辑,大体如下: [代码]Page({ data: { // 当前真实页面的路径 pagePath: '', }, // 真实页面的实例 pageInstance: null, onLoad() { // 根据网络接口返回数据,得到当前容器页面应当显示的真实页面路径 this.setData({ pagePath: someDataFromNet.pagePath, }); }, onShow() { this.pageInstance?.onShow?.(); }, onReady() { this.pageInstance?.onReady?.(); }, /* 其他生命周期 */ }) [代码] 我们现在面临一个问题,那就是我们是使用分包异步化组件进行加载真实页面,那真实页面是什么时候加载成功的呢?我们知道当组件加载成功后,会执行组件的 [代码]lifetimes.attached[代码] 生命周期, 那既然页面可以当成组件用,那页面是否也有这个生命周期呢?通过查阅文档,我们知道了可以在页面中使用[代码]Behavior[代码], 我们可以通过[代码]Behavior[代码]中定义 [代码]lifetimes.attached[代码],在其中通过 [代码]this.triggerEvent('pageattached')[代码] 去通知容器页面,现在我们的 wxml 需要做一些修改,如下: [代码]<pack_a_page_1 wx:if="{{ pagePath === '/pack_a/page_1' }}" bind:pageattached="onPageAttached" /> <pack_b_page_2 wx:elif="{{ pagePath === '/pack_b/page_2' }}" bind:pageattached="onPageAttached" /> <!-- 其他页面节点 --> [代码] js 中也要增加 [代码]onPageAttached[代码] 方法,如下: [代码]Page({ /* 其他生命周期方法 */ onPageAttached() { const route = this.data.pagePath.slice(1); const id = route.replace(/\//g, '_'); const page = this.selectComponent(`#${route}`); page.route = route; page.options = {}; // 补全其他信息, // 调用对应生命周期方法, page.onLoad?.(page.options); // 由于组件可能加载得比较晚,容器页面的 onShow 和 onReady 已经执行过了,这里需要手动执行一遍真实页面的 onShow 和 onReady // 还需要额外做一些判断,避免 onShow 连续执行多遍 page.onShow?.(page.options); page.onReady?.(page.options); }, }) [代码] 好了,准备工作基本上做完,现在就差给所有页面加上我们之前写的[代码]Behavior[代码]了,如果项目一开始就封装了[代码]BasePage[代码]之类的方法,我们只需要在 BasePage 将这个[代码]Behavior[代码]加到[代码]BasePage[代码]中就行,如果没有的话,可以通过改写[代码]Page[代码]去实现,这里就不举例了。 现在我们按照上面的步骤生成5个容器页面并且加入到 [代码]app.json[代码] 中了,然后开始下一步了, 等等。。。5个容器页面?生成的代码有5份!不行,这样会平白占用很多主包空间的,我们需要做一些优化:将生成5个容器页面优化成生成一个容器组件,然后在5个容器页面内去引用该组件,并修改上面的一些逻辑,这样生成的代码就基本上少了1/5,还是很可观的。 现在还差封装[代码]switchTab[代码]方法了,在其中将[代码]url[代码]替换成容器页面的地址,然后记录该容器页面需要展示的真实页面地址,在容器页面中加载对应的真实页面即可。亦可改写 [代码]wx.switchTab[代码] 去调用我们封装的 [代码]switchTab[代码] 方法,在此就不举例了。 好了,现在基础步骤已完成,就差看效果了。 咦,好像还差某些东西,页面标题呢?怎么不能下拉刷新了?这个页面好像是没有顶部导航栏的呀。 我们一个一个来。 标题及背景颜色等 还记得之前生成的 [代码]tab-bar-page-config.js[代码] 吗?我们在其中记录了页面的一些信息,现在,我们需要在运行时去调用微信API设置标题,颜色等信息。解决。 下拉刷新 微信没有提供是否启用下拉刷新的API,所以我们只能给所有容器页面都加上下拉刷新,然后 [代码]onPullDownRefresh[代码] 中判断如果当前真实页面没有启用下拉刷新,就调用[代码]wx.stopPullDownRefresh[代码]停止下拉刷新,否则就调用真实页面的[代码]onPullDownRefresh[代码]钩子。额。。。勉强算解决吧。 顶部导航栏 微信同样没提供是否启用顶部导航栏的API,故只能将5个容器页面分成2类,2个是不带顶部导航的,剩下3个是带顶部导航的,在我们封装的 [代码]switchTab[代码] 中增加判断要跳转的页面是否是包含顶部导航的,分别落到不同的容器页面上即可。解决。 至此,动态Tab页面基本上实现了,还有些样式上的兼容问题,如:某个页面的wxss声明了 [代码]page { backgroud: 'red'; } [代码] 那容器页面内的所有页面都会被影响,对此我们只能在页面的[代码]wxss[代码]中不使用[代码]标签选择器[代码],实际上在微信开发者工具中,使用[代码]标签选择器[代码]是会报警告的,但是口头约束是没有用的,还是会有人会写,故我们引入了[代码]postcss[代码],编写插件使在构建时将标签选择器去掉,并且报出警告。 至此,功能基本完成,需要做的就是验证哪些功能出现了问题,做出相应的修改。 结尾 分包异步化作为一个新出现的特性,还存在一些不稳定,如在开发者工具中,经常出现加载失败的问题,ios 真机调试报错等问题,且要求的最低SDK版本为[代码]2.17.3[代码],要在生产环境中使用还需要做很多的验证工作,也希望微信官方能尽早修改开发者工具中的问题。
2021-11-29 - 【物流-查询组件】1000+快递公司物流信息免费接入
快递100“快递跟踪”微信小程序插件上线啦! 帮助所有小程序解决快递物流查询问题,现面向所有第三方小程序、小程序开发服务商(包括个体小程序)免费开放。 目前已经有1000家小程序申请接入了快递100【快递跟踪】插件, 点链接→https://fuwu.weixin.qq.com/service/detail/00008caeab84c07c17dcdabf55b815,立即添加插件(添加时请从电脑端打开链接) 公开数据显示,今年上半年微信小程序数量已超过430万。 随着小程序生态不断发展,越来越多商家和开发者在小程序上建立自有商城。大到京东这样的巨型平台,小到一个公众号、博主自己开的店铺,用户都可以在小程序上下单。业务逐渐壮大后,物流却成为困扰不少商家和开发者的一大难题。 大平台有大量的资金和人力来调配资源,自主开发接入物流公司系统,给顾客及时物流反馈;而对于那些中小店铺的小程序商家们来说,没有足够人力、财力支撑,无法自主开发接入。 近日,中国领先的快递物流信息服务商快递100宣布,正式上线“快递跟踪”小程序功能插件,开放快递物流信息查询模块,允许第三方小程序 免费 接入。 “快递跟踪”小程序插件整合了快递100快递查询能力,支持全球1000+快递物流公司信息查询,对全行业的小程序免费开放接入,包括电商平台、商家、医药寄送、信息查询或本地生活服务平台等 任何有物流查询需求的小程序开发者,为企业、商家、个体小程序赋能。 点链接→https://fuwu.weixin.qq.com/service/detail/00008caeab84c07c17dcdabf55b815,立即添加插件 01 无门槛免费接入 无论是电商商城,还是社群团购、回收类等任何有涉及快递物流环节的小程序,物流信息查询是必须重视的一项服务。卖家是否能提供及时的物流信息更新服务,会影响到用户的二次购买决策。 “快递跟踪”小程序插件,是免费接入。接入插件后,用户只要在小程序内点击快递单号,就可以查看最新物流信息,有效提升用户的购物体验,提高小程序的回访率和复购转化率。 02 原生体验,无第三方跳转 快递100“快递跟踪”插件依托微信小程序生态,第三方小程序接入后无需任何跳转,在自己的小程序内即可直接查看物流信息,简化用户操作流程。 [图片] 接入方式也非常简单快捷,模板化快速接入,无需再次开发,几个小时即可完成接入,大大降低开发和运营成本。 快递100“快递跟踪”插件开放接入,不仅能够帮助小程序开发者降低物流服务的开发门槛和成本,同时也为小程序商家提供了更好服务用户的方式。 03 支持国内外1000+家快递公司物流查询 通过快递100“快递跟踪”小程序插件,支持全网快递物流查询,可查看国内、国际1000+快递物流公司的信息,同时还提供官方客服热线。 “快递跟踪”插件服务稳定,让商家、开发者管理更加方便。 除了常见的电商场景,“快递跟踪”插件同样非常适合有特定物品物流信息查询需求的机构和企业接入 —— 例如医院类公众号,病历档案预约寄出后的进度查询;驾校机构,寄出驾照后的进度查询;校园机构的报到证、档案等资料的快递查询等。 点链接→https://fuwu.weixin.qq.com/service/detail/00008caeab84c07c17dcdabf55b815,立即添加插件 另外,快递100也可提供快递信息推送、实时快递、地图轨迹API等服务,点这里→https://api.kuaidi100.com/ 了解详情 快递100是中国领先的快递物流信息服务商,国家高新技术企业、新基建代表企业。 快递100目前拥有个人注册用户1.6亿,企业客户60万+,日均查询量3亿次,是国内查询量最大的快递物流信息查询平台;年寄件量超8亿单,寄件功能官方合作京东、邮政、德邦、圆通、韵达、DHL、TNT、UPS等多家国内外快递公司。 快递100致力构建中国最大的物流信息服务枢纽,始终秉承开放态度与快递行业共创共赢,为用户、商家、企业提供专业、可靠的服务,实现互联互通互动。 点链接→https://fuwu.weixin.qq.com/service/detail/00008caeab84c07c17dcdabf55b815,立即添加插件
2021-11-26 - 微信小程序动画实现方案
微信小程序动画实现方案 背景 基于商城业务需求背景下,实现加购动画;具体要求如下: 动画落点不准 体验优化,动画先行,每点击一次需要触发一次动画 减少动画掉帧,卡;避免动画延迟,解决加购卡顿问题 基于以上诉求,对微信小程序各种动画实现方案做了简单的对比分析 动画落点不准确 最开始实现方案,就是在dom ready 的时候去获取元素落点,缓存下来,后续复用改落点即可; [代码] onReady() { // 获取落点坐标 this.getBubblePos('#end-point'); }, [代码] 然而在商城当前业务中,这种方案,获取到的坐标频繁出现较大偏差。基于现状,延迟获取落点坐标,出现以下方案(timeout 和 nextTick 都尝试了,依然没能解决问题,于是综合了一下) [代码] onReady() { // 获取落点坐标 this.$nextTick(() => { setTimeout(() => { this.getBubblePos('#end-point'); }, xxxx); }) }, [代码] [代码]timeout[代码]方案尝试了不同的延迟时间,发现在1s内,依旧会出现 获取到的位置不准确,初步判断跟加购动画落点所在的结算条的条件渲染有关。timeout 解决不了问题。 最终在x大佬的指导下,参考了其他平台的实现方案,采取了点击时获取落点坐标,解决了该问题; [代码] async startAnimation(e) { //点击时 获取落点 并 缓存 await this.setStartPos(`#end-point`); // 开始动画 this.useAnimateApi(0); }, [代码] 快速点击下能连续触发,多个动画并存 需要有多个动画元素,保证快速点击的时候,动画不延迟,并且每次点击都能触发一个动画元素执行动画;动画元素要求能购复用,减少冗余dom 元素; 业务现状: 现有加购按钮跟落点所在的结算条位于不同的组件内;点击时获取到点击位置的坐标,然后需要将动画元素从点击位置 移动到落点; 解决方案: 每个页面初始化 [代码]n[代码] (n=10)个元素隐藏在 页面内部 css 隐藏到页面某个区域;为了回收复用动画元素 用一个队列来维护当前可使用动画元素; [代码] <!-- css 元素2个元素实现 --> <block wx:for="{{transition}}" wx:key="id"> <view class="bubblebox bubblebox_{{index}}" style="{{item.parent}}"> <view class="bubble bubble_{{index}}" bind:transitionend="transitionEnd" data-index="{{index}}" style="{{item.child}}"></view> </view> </block> [代码] [代码]// 维护动画元素队列,动画执行完成之后 回收当前动画元素 transitionEnd(e) { console.log(e); // 监听动画结束时间 清除动画 const { index = 0 } = e.currentTarget.dataset || {}; this.data.queue.push(Number(index)); this.data.transition.splice(index, 1, { id: index, parent: '', child: '' }); this.setData({ transition: this.data.transition, }); }, [代码] 掉帧 卡顿问题 针对该问题,对各个实现方案做了个对比 Note: 商城加购动画 最终实现方案采用 css transition 实现 [代码]wx.createAnimation[代码] [代码] useCreateAnimation(index) { const { x: x1, y: y1 } = this.data.startPos; const { x: x2, y: y2 } = this.data.dropPos; // 1. 移动到 起点 // duration 不能为0 最小为1 this.animation.translate(x1, y1).opacity(1).step({ duration: 1, delay: 0 }); // const parent = this.animation.export(); // // 2. 起点移动到 落点 this.animation.translate(x2, y2).step({ duration: 1000, delay: 1 }); // // // 3. 隐藏气泡 回到起点 this.animation.opacity(0).translate(0, 0).step({ duration: 1, delay: 1000 }); const child = this.animation.export(); this.data.transition.splice(index, 1, { id: index, parent: '', child }); this.setData({ transition: this.data.transition, }); }, [代码] [代码]this.animate[代码] 从小程序基础库 2.9.0 开始 [代码] useAnimateApi(index) { const { x: x1, y: y1 } = this.data.startPos; const { x: x2, y: y2 } = this.data.dropPos; console.log(`useAnimateApi`, index, x1, y1, x2, y2); // 2. 上 下 移动动画 this.animate(`#bubble_${index}`, [{ left: `${x1}px`, top: `${y1}px`, opacity: 1 }, { translate: [`${x2 - x1}px`, `${y2 - y1}px`] }], 500, () => { console.log('animationEnd'); this.clearAnimation(`#bubble_${index}`, () => {}); this.animationEnd(index); }); }, [代码] [代码]css3 transition[代码] [代码] useCssAnimate(index) { const { x: x1, y: y1 } = this.data.startPos; const { x: x2, y: y2 } = this.data.dropPos; const parent = `transition: transform 0ms 0ms linear;transform: translate(${x1}px,${y1}px)`; const child = `transition:opacity 0s 0s,transform 2s 0ms linear;transform: translate(${x2 - x1}px,${y2 - y1}px);opacity:1;`; this.data.transition.splice(index, 1, { id: index, parent, child }); this.setData({ transition: this.data.transition, }); }, [代码] [代码]wxs[代码] 相应事件 小程序双引擎设计,setData 是影响性能的关键因素,wxs 相应事件能减少 setData 次数,有助于提高动画性能; [代码]<wxs module="utils"> var transition = function(e, ownerInstance) { var index = 0 var parent = ownerInstance.selectComponent('#bubblebox_1_'+index) // 返回组件的实例 var child = ownerInstance.selectComponent('#bubble_1_'+index) // 返回组件的实例 var x1 = 300 var y1 =50 var x2 = 50 var y2 = 278 console.log(parent,child) parent.setStyle({ transition: 'transform 0ms 0ms linear', transform: 'translate(300px,50px)' }) child.setStyle({ transition:'opacity 0s 0s,transform 2s 0ms linear', transform: 'translate(-250px,238px);opacity:1' }) return false // 不往上冒泡,相当于调用了同时调用了stopPropagation和preventDefault } module.exports = { transition:transition } [代码] </details> [代码]css3 animation[代码] 待尝试,动画生成keyframes 如何绑定到 dom 上? [代码]css transition[代码] 细节实现 分析当前动画元素过程 css 元素从隐藏的位置(fixed到左上角,(0,0))移动到起点,并可见; [代码]transition:opacity 0s 0s,top 0s 0s,left 0s 0s,transform 2s 0ms linear; transform: translate(1px,1px);opacity:1; top:100px; left:100px; [代码] 通过top,left 移动元素到起点,并且 通过opacity 控制元素展示出来;执行 translate 动画 利用[代码]transition[代码] 可以同时为不同的 [代码]动画属性[代码] 指定不同的 [代码]duration[代码] [代码]timeFunction[代码] [代码]delay[代码] 动画结束 移除动画元素绑定的 style,动画元素回到起点;动画结束,维护可使用的动画元素队列;监听动画结束事件,回收当前元素入队。 [代码] <view bind:transitionend="transitionEnd"></view> [代码] [代码] transitionEnd(e) { console.log(e); const { index = 0 } = e.currentTarget.dataset || {}; // 动画元素 入队 this.data.queue.push(Number(index)); // 监听动画结束时间 清除动画 this.data.transition.splice(index, 1, { id: index, parent: '', child: '' }); this.setData({ transition: this.data.transition, }); }, [代码] 可以用1层dom,为何用2层dom ? 虽然元素已经脱离了文档流,top left 并不会触发 重绘 引起性能问题;但是不能充分利用css3 硬件加速的能力。 完美解决??? 该方案对 dom性能有较大提高,但是消耗的内存占用也不容小觑。目前商城业务高度复杂,内存占本来就高的情况下,改方案依旧会在内存占用过高的情况下[代码]闪现[代码],过渡态消失的情况。 抛物线方案 该方案 必须使用2层view元素实现 抛物线方案,改方案初步实现后在小程序里面不同机型上 效果差异较大,动画方案回退为直线; 具体步骤: 移动到起点 父级元素 向左边移动 同时子级元素先向上移动指定距离,再向下(时间函数控制曲线斜率) 回收元素 参考: [代码]// 1. 抛物线 const parent = `transition: top 0s 0s, left 0s 0s, opacity 0s 0s, visibility 0s 0s, transform 500ms 0ms ease-in;transform: translateX(${ x2 - x1 }px);left:${x1}px;top:${y1}px;opacity:1;visibility:visible;` const child = `transition: transform 300ms 200ms ease-in, margin-top 200ms 0ms ease-in-out,opacity 0ms 501ms linear,visibility 0ms 501ms linear;margin-top: -66px;transform: translateY(${ y2 - y1 + 66 }px);opacity:0;visibility:hidden;` [代码] Refer weixin miniprogram wxs 响应事件 浏览器的重绘和回流(Repaint & Reflow)
2021-12-13 - 小程序中使用rxjs,是真的舒服!
rxjs-mp 在小程序中使用RxJs。(RxJs for miniprogram) 1. 安装依赖: [代码]npm install --save rxjs-mp[代码] [代码]npm install --save-dev rxjs[代码] 安装完成后使用小程序开发者工具构建npm,然后就可以在项目中使用rxjs了。此外,在vscode里还能享受rxjs的语法提示。 2. 如何使用: 为了演示在小程序中如何使用rxjs,现在举一个用rjxs封装http请求的例子。相对于Promise的封装,rxjs可随时取消已经发出的请求,这一点是Promise很难实现的。我们在utils目录下建一个http.js的文件。 2.1 封装utils/http.js [代码]const Rx = require('rxjs-mp'); /** * Request 方法 * @param {string} url * @param {any} data * @param {'GET'|'POST'|'PUT'|'DELETE'} method * @param {{header?: object}} options */ function request(url, data = {}, method = "GET", { header }={}) { return new Rx.Observable(ob => { const requestTask = wx.request({ url, data, method, header: { ...(header || {}) Token: wx.getStorageSync('token') }, success: res => { if (res.statusCode == 200) { ob.next(res); ob.complete(); } else { ob.error(res); } }, fail: err => { console.error(err); ob.error(err); } }); return () => requestTask.abort(); }); } /** * GET 方法 * @param {string} url * @param {{header?: object}} options */ function get(url, options) { return request(url, null, 'GET', options); } /** * POST 方法 * @param {string} url * @param {any} data * @param {{header?: object}} options */ function post(url, data, options) { return request(url, data, 'POST', options); } /** * PUT 方法 * @param {string} url * @param {any} data * @param {{header?: object}} options */ function put(url, data, options) { return request(url, data, 'PUT', options); } /** * GET 方法 * @param {string} url * @param {{header?: object}} options */ function delete_(url, options) { return request(url, null, 'DELETE', options); } module.exports = { http: { get, post, put, delete: delete_, request } } [代码] 2.2 调用封装,pages/your/page.js 管理rxjs订阅可以使用subsink2订阅管理工具(安装:[代码]npm install subsink2[代码]). [代码]const Rx = require('rxjs-mp'); const { SubSink } = require('subsink2'); const http = require('../uitls/http.js'); const subs = new SubSink(); Page({ data: { todoList: [], }, onLoad() { // 发起请求 this.httpRequest01(); this.httpRequest02(); this.httpRequest03(); }, httpRequest01() { // 请求1,用subsink的id方法标识请求,可以防重得请求,还可以随时取消 subs.id('sub01').sink = http.get('htts://some-api-url').pipe( Rx.operators.map(res => res && res.data || {}) ).subscribe(res => { console.log(res); }, err => { console.error(err); }); }, httpRequest02() { // 请求2,直接把请求加入订阅池 subs.sink = http.post('htts://some-api-url', {}).pipe( Rx.operators.map(res => res && res.data || {}) ).subscribe(res => { console.log(res); }, err => { console.error(err); }); }, httpRequest03() { // 请求2,把请求加入订阅池的另一种方法 subs.add(http.get('htts://some-api-url').pipe( Rx.operators.map(res => res && res.data || {}) ).subscribe(res => { console.log(res); }, err => { console.error(err); })); }, bindUnSubRequest01() { // 取消request01的请求 subs.id('sub01').unsubscribe(); }, onUnload() { // 销毁时,取消所有请求 subs.unsubscribe(); }, }); [代码] 2.3 你还可使用rxjs其它强大的功能 比如消息发布和订阅,rxjs基于流的响应式编程帮你逃出Promise的回调地狱。 [代码]// ================================================ // utils/message.js // ================================================ const Rx = require('rxjs-mp'); module.exports = { onSomeThingChange$: new Rx.Subject(), onOtherThingChange$: new Rx.BehaviorSubject(false), } // ================================================ // pages/your/page.js // ================================================ const Rx = require('rxjs-mp'); const { onSomeThingChange$, onOtherThingChange$ } = require('../../utils/message.js'); onSomeThingChange$.pipe( Rx.operators.first() ).subscribe(status=> { console.log(status) // doSomeThing }); onOtherThingChange$.subscribe(status => { console.log(status) // doSomeThing }); onSomeThingChange$.next(true); onOtherThingChange$.next(true); [代码]
2021-03-31 - for循环,图片批量鉴黄?
我理想中的结果是一张一张图片鉴黄,把鉴定违规的图片删除,但是现在的结果如图: [图片] //上传图片 onChangeFlockData: async function (e) { let imgUrls = e.detail.all; //上传的图片数组 for (let i = 0; i < imgUrls.length; i++) { await this.imgInfoCheck(imgUrls[i].url) } }, //获取图片信息 imgInfoCheck: async function (url) { wx.getImageInfo({ src: url, }).then(async (res) => { console.log("图片信息:", res) const imgInfo = res.path; const imgWidth = res.width; const imgHeight = res.height; await this._compressImage(imgInfo, imgWidth, imgHeight); }) }, //图片压缩 _compressImage: async function (imgInfo, imgWidth, imgHeight) { const query = wx.createSelectorQuery() query.select('#canvas') .fields({ node: true, size: true }) .exec(res => { const canvas = res[0].node; const ctx = canvas.getContext('2d'); const dpr = wx.getSystemInfoSync().pixelRatio; const imgW = Math.trunc(imgWidth / dpr); const imgH = Math.trunc(imgW / imgWidth * imgHeight); canvas.width = imgW; canvas.height = imgH; ctx.clearRect(0, 0, imgW, imgH); this.setData({ canvasWidth: imgW, canvasHeight: imgH }); let imageObj = canvas.createImage(); imageObj.src = imgInfo; imageObj.onload = (res) => { ctx.drawImage(imageObj, 0, 0, imgW, imgH) }; const cfgSave = { fileType: "jpg", quality: 0.5, width: imgW, height: imgH, destWidth: imgW, destHeight: imgH, canvas: canvas, }; wx.canvasToTempFilePath({ ...cfgSave, }).then(async res => { let tempUrl = res.tempFilePath console.log("压缩后图片:", res) await this.imgSecCheck(tempUrl) }) }) }, //图片送审 imgSecCheck: async function (tempUrl) { wx.showLoading({ mask: true }); wx.getFileSystemManager().readFile({ filePath: tempUrl, encoding: "base64", success: function (res) { let imgBuffer = res.data wx.cloud.callFunction({ name: "msgSecCheck", data: { type: 'imgSecCheckBuffer', //以buffer方式上传送审 value: imgBuffer, } }).then(res => { console.log("图片检测结果:", res.result.errCode) wx.hideLoading() if (res.result.errCode === 87014) { wx.hideLoading() wx.showToast({ title: '图片含有违法违规内容', icon: 'none' }) return } // this._uploadImg(tempUrl) }).catch(err => { console.log(err) wx.hideLoading() wx.showModal({ title: '提示', content: '图片尺寸过大,请调整图片尺寸', success(res) { if (res.confirm) { console.log('用户点击确定') } else if (res.cancel) { console.log('用户点击取消') } } }) }) }, fail: err => { console.error(err); } }) },
2021-05-16 - 华为mate30 drawImage行为和其他手机不一致?
华为mate30 canvas 2d drawImage行为和其他手机不一致 使用canvas 2d, const { pixelRatio } = wx.getSystemInfoSync; canvas.width = res[0].width * pixelRatio; canvas.height = res[0].height * pixelRatio; // 华为mate30机型 dx,dy,dWidth, dHeight需要除掉pixelRatio,才会显示正常 ctx.drawImage(img, 100, 100, cw * dpr, ch * dpr, 0, 0, w / pixelRatio, h / pixelRatio); // 非华为mate30 dx,dy,dWidth, dHeight不需要除掉pixelRatio,就可以显示正常 ctx.drawImage(img, 100, 100, cw * dpr, ch * dpr, -w, 0, w, h);
2021-05-14 - icebreaker手把手教你定制小程序码
icebreaker手把手教你定制小程序码 前言 小程序菊花码,相比与普通的二维码,辨识度高,一看就知道拿微信扫。 默认情况下,我们可以自定义生成码的 [代码]参数[代码], [代码]路径[代码], [代码]大小[代码], [代码]自动或手动配置线条颜色[代码],[代码]底色是否为透明[代码] 这些配置项。 然而,这些配置项往往是无法满足我们的定制化需求的。 举个例子,我们需要在不破坏 [代码]小程序码[代码] 可识别性的情况下,把中间的 [代码]Logo[代码] 替换掉,怎么做呢? 接下来就由笔者手把手来教你。 梳理思路 我们先要理清楚这个问题的本质。这个其实就是个 图像处理 问题, 而这个工作服务端和客户端都能做。 于是就有 [代码]2[代码] 个方案: 服务端生码并且缝合出结果的 服务端处理方案。具体怎么做,有兴趣的同学,可以查看笔者的这篇文章 Web 函数自定义镜像实战:构建图象处理函数 服务端生码,客户端缝合方案。这就是本篇文章具体提及的。 注:小程序码一般由服务端调用微信api接口生成 云调用——生码最简便方案 总所周知,微信小程序环境的 [代码]wxacode.getUnlimited[代码] 有 [代码]2[代码] 种生成模式,一种为 [代码]HTTPS 调用[代码] , 一种为 [代码]云调用[代码]。 其中 [代码]云调用[代码] 作为一种场景定制化的 [代码]serverless[代码]解决方案,往往能为我们的开发,带来效率上的提升。我们接下来快速部署一个[代码]getWxacodeUnlimit[代码]函数,来为我们提供测试素材。 [代码]getWxacodeUnlimit/index.js[代码]: [代码]import cloud from 'wx-server-sdk' cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) export async function main (event, context) { const { scene = '', page, width = 430, autoColor, lineColor, isHyaline } = event const result = await cloud.openapi.wxacode.getUnlimited({ scene, page, width, autoColor, lineColor, isHyaline }) return result } // 笔者使用了打包工具, 大家想直接跑,把 esm 转化为 cjs 即可 [代码] [代码]getWxacodeUnlimit/config.json[代码]: [代码]{ "permissions": { "openapi": ["wxacode.getUnlimited"] } } [代码] 通过上述 [代码]2[代码] 段代码块,我们的测试函数就部署完成了。 把返回的 [代码]buffer[代码] 转成本地临时图片 [代码]const suffixMap = { 'image/jpeg': 'jpeg', ... } export function getPath(filename = 'tmp', contentType = 'image/jpeg') { return `${wx.env.USER_DATA_PATH}/${filename}.${suffixMap[contentType] || 'jpeg'}` } export function writeFile(buff, contentType = 'image/jpeg', filename = 'tmp') { return new Promise((resolve, reject) => { const fsm = wx.getFileSystemManager() const filePath = getPath(filename, contentType) fsm.writeFile({ filePath, data: buff, encoding: 'binary', success() { resolve(filePath) }, fail(error) { reject(error) } }) }) } .... // 在需要用到的地方直接 try { loading('生成中') const result = await getQrcode(scene, option) // 云调用封装function return await writeFile(result.buffer, result.contentType, scene) // : string } catch (e) { console.error(e) } finally { loaded() } [代码] 客户端的图像处理 提到客户端图像处理就不得不提到 [代码]canvas[代码] 这个原生组件了,所以我们只需要通过它,把小程序码中间的 [代码]Logo[代码] 部分,进行 测量和裁剪替换 为我们想自定义的图像就可以了。 测量 这里以默认小程序码大小 [代码]430px * 430px[代码] 为例。(本[代码]case[代码]为了简单易懂,都使用的此分辨率的小程序码,如需求分辨率不同,可按比例进行计算裁剪。) [图片] 从图上的标注可知,在 [代码]430px * 430px[代码] 分辨率下,上下左右的边距为 [代码]120px[代码] ,可以算出中间 [代码]Logo圆形[代码] 的直径为 [代码]190px[代码], 半径为 [代码]95px[代码]。 所以接下来就可以轻松愉快的写代码了。 利用canvas 2d实现 1代小程序 api 版本被淘汰了,现在直接使用 type=“2d” 版本,Api文档在 [代码]MDN[代码] 上 前置标签和样式 [代码]<!-- uni-app vue 格式,可自行转化为 wxml 简单的语法转化 `: => {{}}` --> <canvas :class="visible ? '': 'canvas offscreen'" type="2d" id="canvas" :style="{ width:width+'rpx', height:height+'rpx' }" ></canvas> [代码] [代码]// scss .canvas.offscreen{ // 2个 class 选择器,增加优先级 position: absolute; bottom: 0; left: -9999rpx; // 这叫物理离屏渲染,笑~ } [代码] 注: 不能在 [代码]canvas[代码]上 使用 [代码]hidden[代码] or [代码]display:none[代码],它们会导致渲染空白。 核心 js 实现 初始化canvas实例和上下文 初始化 [代码]canvas[代码] 实例和 [代码]ctx[代码] 上下文: [代码]let canvas let ctx {...codes...} onReady(){ uni .createSelectorQuery() .in(this) // 如果canvas在组件中,则需要加这一行 .select('#canvas') .fields({ node: true, size: true }) .exec((res) => { if (res[0]) { this.canvas = canvas = res[0].node this.ctx = ctx = canvas.getContext('2d') // 下面可根据设备的 pixelRatio 自行按比例调整,此处为了演示方便,就直接赋值了。 canvas.width = 430 canvas.height = 430 } }) } [代码] 第一次渲染-画布背景 第一次渲染,把小程序码,作为图像传入画布。 [代码]drawBackgroud[代码]: [代码]async drawBackgroud (orginQrcodeUrl) { const [err, res] = await uni.getImageInfo({ // 这里可以是远程地址(需要配置downloadUrl) // 也可以是本地地址(直接返回参数自己) // 甚至 cloud:// 前缀的云存储url也可以哟 src: orginQrcodeUrl }) if (err) { throw err } const { path } = res const img = canvas.createImage() img.src = path await new Promise((resolve, reject) => { img.onload = () => { // 下面这一行,把小程序码,整个铺进画布中! ctx.drawImage(img, 0, 0, canvas.width, canvas.height) resolve() } img.onerror = (event) => { reject(event) } }) }, [代码] 第二次渲染-裁剪加填充 第二次渲染,把背景裁剪一个圆,并把图片填充进去。 [代码]drawAvatar[代码]: [代码]async drawAvatar (remoteAvatarUrl) { const [err, res] = await uni.getImageInfo({ // 比如这里我用了云储存里的图像地址 prefix: cloud:// src: remoteAvatarUrl }) if (err) { throw err } const { path } = res const img = canvas.createImage() img.src = path // 测量数据在这里用上了 const offsetX = 120 // x 轴偏移 120px const offsetY = 120 // y 轴偏移 120px const diam = 190 // 圆的直径 (430 - 120* 2) / 2 const radius = diam / 2 // 圆的半径 const borderWidth = 2 // 多加2px来把原先logo的纯色边抹除 const circle = { // 裁剪部分圆的大小属性 x: offsetX + radius, y: offsetY + radius, radius: radius + borderWidth } await new Promise((resolve, reject) => { img.onload = () => { ctx.save() // 开始! 把原先中间的Logo干掉! ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI 2, false) ctx.clip() // 结束 // 开始! 把我们需要的自定义图像,平铺的插入进去! ctx.drawImage( img, offsetX - borderWidth, offsetY - borderWidth, circle.radius * 2, circle.radius * 2 ) // 结束 ctx.restore() resolve() } img.onerror = (event) => { reject(event) } }) }, [代码] 通过以上几步 ,我们就可以轻松的完成图像处理部分,把中间的默认[代码]Logo[代码]给替换成自定义的图像。 预览及下载到本地 [代码]// 获取 tempFilePath async getImage () { const [err, res] = await uni.canvasToTempFilePath({ canvas }) if (err) { throw err } return res.tempFilePath } // 预览 async preview (src) { if (src) { uni.previewImage({ urls: [src] }) } }, // 保存到相册里 async save (src) { try { // 先授权,后保存 await authorize('scope.writePhotosAlbum') const [err, res] = await uni.saveImageToPhotosAlbum({ filePath: src }) if (err) { throw err } this.$success('保存成功!') } catch (e) { console.error(e) } } [代码] 就这样,客户端生成自定义小程序码的整套解决方案就完成了 效果展示 [图片] 或者微信搜索 [代码]程序员名片[代码] 后,维护名片,上传头像,再点击下方 [代码]分享二维码[代码] 按钮,即可预览。 [图片] 自定义生成转发图片 这篇文章这个案例还算是非常简单的,笔者之前还写过一个 小程序Canvas 2D自定义生成转发图片, 还有自定义分享海报,这些原理上都是大同小异的,一通百通。 附录 wxacode.getUnlimited接口文档
2021-09-27 - 开发者工具,虚拟机里面用共享目录报错呀,导入和新建都不行?
宿主机是ubuntu19.04,虚拟机是virtualbox6,虚拟机系统是win10 64位,开发者工具版本v1.02.1907300。 virtualbox共享了一个目录,虚拟机里面的win10映射成一个网络驱动器z盘。 从z盘导入或者新建项目保存到z盘都报错,项目是小程序项目。 [代码]VM26:1 EISDIR: illegal operation on a directory, [代码][代码]watch[代码] [代码]'Z:/'[代码][代码]Error: EISDIR: illegal operation on a directory, [代码][代码]watch[代码] [代码]'Z:/'[代码][代码] [代码][代码]at FSWatcher.start (internal[代码][代码]/fs/watchers[代码][代码].js:165:26)[代码][代码] [代码][代码]at Object.[代码][代码]watch[代码] [代码](fs.js:1274:11)[代码][代码] [代码][代码]at new FileUtils (C:\Users\dev\AppData\Roaming\Tencent\微信开发者工具\package.nw\core.wxvpkg\8e2026561e71ea67df211489b756510c.js:42:30)[代码][代码] [代码][代码]at C:\Users\dev\AppData\Roaming\Tencent\微信开发者工具\package.nw\core.wxvpkg\d62fc37d7aa6416d5dcc240ba94175cd.js:23:20[代码][代码] [代码][代码]at new Promise (<anonymous>)[代码][代码] [代码][代码]at module.exports (C:\Users\dev\AppData\Roaming\Tencent\微信开发者工具\package.nw\core.wxvpkg\d62fc37d7aa6416d5dcc240ba94175cd.js:20:10)[代码][代码] [代码][代码]at Object.apply (C:\Users\dev\AppData\Roaming\Tencent\微信开发者工具\package.nw\node_modules.wxvpkg\lazyload\lazy-require.js:44:20)[代码][代码] [代码][代码]at module.exports (C:\Users\dev\AppData\Roaming\Tencent\微信开发者工具\package.nw\core.wxvpkg\60e94018e5c42875e658435ea04a006d.js:1:2606)[代码][代码] [代码][代码]at Object.apply (C:\Users\dev\AppData\Roaming\Tencent\微信开发者工具\package.nw\node_modules.wxvpkg\lazyload\lazy-require.js:44:20)[代码][代码] [代码][代码]at module.exports (C:\Users\dev\AppData\Roaming\Tencent\微信开发者工具\package.nw\core.wxvpkg\162bf2ee28b76d3b3d95b685cede4146.js:1:430)[代码] [图片]
2019-09-28 - [填坑手册]小程序web-view组件实战与踩坑
[图片] 首先,根据官网文档可以知道 只有非个人 的小程序才可以使用web-view组件,如果你的个人开发者,可以跳过这篇文章。 [图片] 一、使用web-view以及它的好处 1、己方账号(第三方)与小程序openId/UnionId的关联绑定,实现免登陆 比如你是某门户网站S,你要识别自己小程序上的用户与网站用户的关系,你可以通过三种方法绑定关系,公众号,小程序源生,小程序web-view内嵌跳转三种方法 2、内嵌H5的富文本,减少重复开发 比如你是门户网站,社区,以往有大量的新闻和帖子,里面带了各种css样式的富文本,小程序源生是无法直接读取的,需要大量转化,这时候直接内嵌这些H5新闻,大大降低开发成本 3、热更新,减少发布审核 某些需要经常更新的内容、公告、活动页,内嵌H5可以减少频繁提交小程序审核 二、小程序功能赋权 为H5提供各种小程序才有的功能,比如录音,扫一扫等。 注意事项 多场景判断,建议使用官方API: wx.miniProgram.getEnv H5唤醒一些小程序API有一定的延时,0.3~1秒 请调用小程序专用的JSSDK,同一个jssdk,但是webview的功能收到限制,和之前微信打开H5有所不同 小程序自动获取加载H5的title H5中iframe的url必须也是业务域名 web-view一定是撑满全屏的,自定义顶部菜单,悬浮的都没用 三、小程序和H5之前的互相通讯 1、 从小程序 ==>> h5 小程序控制H5,可以直接用src路径传参的形式,比如 [代码]<!-- 小程序端 HTML --> <web-view src="//URL?a=param1&b=param2"></web-view> [代码] 避免在链接中带有中文字符,在 iOS 中会有打开白屏的问题,建议加一下 encodeURIComponent。 2、 从 H5 ==>> 小程序 [图片] 这里我们知道bindmessage是小程序用来监听H5的推送的内容,但是这里不大不小的坑!就是它的三个出发场景: 小程序后退:使用接口名 wx.miniProgram.navigateTo,wx.miniProgram.navigateBack,wx.miniProgram.switchTab 等切换小程序页面/场景的API时候都会出发 分享:这个就是当你点分享小程序的时候,会接受到H5之前发送的postMessage 组件销毁,web-view组件销毁,类似 wx.miniProgram.redirectTo 都会触发。 [代码]<!-- 小程序端 HTML --> <web-view bindmessage="handleGetMessage" src="{{openUrl}}"></web-view> [代码] [代码]// 小程序端 JS --> Page({ /** * 页面的初始数据 */ data: { openUrl: "", }, /** * 获取请求数据 */ handleGetMessage: function (e) { console.log(e.detail.data); } }, }) [代码] [代码]<!-- h5端 HTML和JS --> <script type="text/javascript" src="https://res.wx.qq.com/open/js/jweixin-1.3.2.js"></script> <script> wx.miniProgram.postMessage({ data: { link: "//test.com", title: "一起学习,一起进步" } }); //wx.miniProgram.redirectTo({ // url:"/pages/inner/index?source=123" //}) wx.miniProgram.navigateBack({delta: 1}) </script> [代码] 注意事项 那些H5控制小程序的跳转路径必须是“/”开头,如 “/pages/xxx/xxx”,且路径必须在app.json里有,地址错误的话,有时不报错。 postMessage的json必须是data开始,不然接收不到数据。 [图片] 四、bindmessage接收到消息有3个重要特性(重点) 接收可以是H5之前几分钟前发送postMessage,不一定是即刻发出的。 之前发出的 postMessage的DATA信息会累加,当触发bindmessage接收的时候是一个数组。 [图片] 当bindmessage 再次 接收到数据,之前发送的数据不会被清空,将累加一起返回,获取时要判断好数组的角标 [图片] 五、Tips 1、在IDE工具中如何调试H5 [图片] 可以在 web-view 组件上通过右键 - 调试,打开 web-view 组件的调试。 2、内嵌H5缓存问题 web-view加载的H5具有很重的缓存,如果需要调试,可以通过在url后面加时间戳的方式解决。 3、小程序关闭,H5背景音乐仍然在播放问题 小程序已经关闭,但是H5自带的背景音乐仍然在手机后台播放的问题。这里可以利用一个属性: visibilitychange:页面可见性状态 简单的说,浏览器标签页被隐藏或显示的时候会触发visibilitychange事件。 [代码]var statusBeforeHide = true; //初始化页面的状态 var music = document.getElementById("xxx"); // 更改音乐播放状态 function setChangeMusic() { if (document[hiddenProperty]) { // 页面隐藏 if (statusBeforeHide) { music.pause(); // 暂停 } } else { // 页面显示 if (statusBeforeHide) { music.play() //如果statusBeforeHide是true, 继续播放 } } } let hiddenProperty = ('hidden' in document) ? 'hidden' : ('webkitHidden' in document) ? 'webkitHidden' : ('mozHidden' in document) ? 'mozHidden' : null; if (hiddenProperty) { let visibilityChangeEvent = hiddenProperty.replace(/hidden/i, 'visibilitychange'); let onVisibilityChange = () => { //console.log('visibilityChange'); setChangeMusic(); }; document.addEventListener(visibilityChangeEvent, onVisibilityChange); } else { console.log("不支持这个api"); } [代码] 总结,web-view还是非常实用的组件,且用且珍惜~ 往期回顾: 小程序自定义头部导航栏“完美”解决方案 小程序Canvas生成海报(一) 小程序新版订阅消息+云开发实战与跳坑
2021-09-13 - web-view 组件外链国务院政策页面,提示不支持非业务域名,又无法配置,请问怎么解决?
需求是需要点击小程序里的某个图片,打开国务院的某个政策页面(比如:http://www.gov.cn/guoqing/2018-03/22/content_5276318.htm) 而我这样写,微信小程序会提示 不支持打开非业务域名https://www.gov.cn,请重新配置。 <web-view id="weburl" src="http://www.gov.cn/guoqing/2018-03/22/content_5276318.htm"></web-view> 如果要实现我描述的需求,请问我该怎么写? (https://www.gov.cn 我没法配置成业务域名,我无法把校验文件放过去)
2020-10-26 - (15)真机定位问题技巧
开发者在开发小程序的时候可能会碰到一些这样的问题: 问题1 开发者工具上看效果没问题,但是在真机上测试不行? 问题2 有用户遇到小程序功能无法使用的问题,但无法快速定位解决? 今天我们的小故事与大家分享一些真机定位的技巧,可以解决上面两个问题。 1 vConsole开发利器和远程调试功能 针对问题1,我们提供了 vConsole 开发利器和远程调试功能,可以协助开发者在定位真机上的问题。 vConsole 的有四个Tab面板,可以先看下 Log 面板,看是否有异常信息,异常类型 thirdScriptError 是框架捕捉到的开发者的代码执行的异常,可以优先处理异常信息看是否可以解决问题。Log 面板可以看到异常出现的文件和行数。 [图片] 除了异常日志,开发者还可以通过 console.log 接口在一些关键执行路径上打日志来定位问题,这些日志会呈现在 Log 面板上。 vConsole 默认是不开启的,可以通过下面2个方法来开启: 1 开发版和体验版可以点击小程序页面右上角的...按钮打开的菜单项“打开调试”来开启 vConsole。 2 正式版没有“打开调试”的菜单项,可以先通过开发版和体验版来开启 vConsole,然后再打开正式版。或者可以预埋一个隐藏操作,比如连续点击某个 Button 多次,然后调用 API 接口 wx.setEnableDebug 来打开。 vConsole 虽然强大,但在手机上查看大量的日志信息不方便,此外,vConsole 没有断点调试、无法修改样式,定位复杂问题需要花费比较多的时间。 小程序的业务逻辑运行在 AppService 层,页面渲染在 WebView 运行,并通过微信客户端通信,因此,我们想到了可以让 AppService 运行在开发者工具,页面渲染还是在手机 WebView,两者通过网络来通信,这样借助开发者工具的调试能力,就可以实现远程调试功能。 远程调试窗口通过手机客户端扫描开发者工具上生成的二维码来打开,无需像普通手机 H5 页面调试一样,需要在手机端进行一些设置。 [图片] 打开的远程调试界面和开发者工具的模拟器的调试界面很像,需要注意的是,要在 Console 里对小程序进行调试,需要将调试的上下文切换到 VM Context 1 。 [图片] 更多的远程调试的使用方法请参考使用文档。 2 意见反馈能力 对于问题2,小程序的使用反馈来自用户投诉,这种情况用户无法联系到开发者。我们遇见过有小程序功能出现问题,用户无法使用,但投诉无门的情况,而这些问题,开发者也没有途径去收集以及处理,这就导致了小程序服务质量下降,用户流失。 为此,我们开发了“意见反馈”功能,当出现问题时,开发者可以引导用户使用“意见反馈”进行反馈,并上传日志来辅助开发者定位问题。操作过程如下: 引导用户进入小程序帐号详情页面,具体可以在小程序界面点击右上角...按钮,选择关于菜单。接着在帐号详情页面点击右上角...按钮,选择意见反馈菜单进入页面。页面可以上传图片和日志,建议用户上传异常情况的截图,以及勾选允许开发者使用小程序日志选项上传日志,反馈信息越详细,越有助于定位问题。 [图片] [图片] 如果觉得上面的操作步骤太麻烦,开发者可以通过在页面 WXML 添加下面的按钮,用户点击按钮可以直接打开“意见反馈”页面。 [图片] 开发者需要定时处理用户的反馈,这样才能保证小程序的质量。开发者可以登录小程序管理后台,进入左侧菜单客服反馈,就可以看到用户的反馈内容以及下载日志来辅助定位问题。 [图片] 为了保证日志信息足够详细,开发者需要用下面的接口在代码的关键执行路径上写日志。 [图片] wx.getLogManager 接口的更详细使用请参考文档。 希望通过这些小技巧,可以帮助大家顺畅地开发小程序。
2019-04-29 - 小程序前后端交互使用JWT
前言 现在很多Web项目都是前后端分离的形式,现在浏览器的功能也是越来越强大,基本上大部分主流的浏览器都有调试模式,也有很多抓包工具,可以很轻松的看到前端请求的URL和发送的数据信息。如果不增加安全验证的话,这种形式的前后端交互时候是很不安全的。 相信很多开发小程序的开发者也不一定都是大神,能够精通前后端,作为小程序的初学者不少人也是根据官方的文档去学习开发的。我自己最开始接触小程序也是从wafer2开始的,那时候腾讯云提供的SDK包含PHP和Node.js,因为对于一直做前端的人来说,Node.js的学习成本比较低,只要会JS基本能看懂,也是从那时候才开始接触Node.js,所以本文主要是基于wafer2的服务端基于Koa2的后端来说(其实这个不重要,Node.js基本都差不多)。 什么是JWT? 根据维基百科的定义,JSON WEB Token,是一种基于JSON的、用于在网络上声明某种主张的令牌(token)。JWT通常由三部分组成: 头信息(header), 消息体(payload)和签名(signature)。 为什么使用JWT? 首先,这不是一个必选方案。有时候我们的API是其它服务端和小程序公用的,那么就涉及到安全验证的问题了。 微信官方不鼓励小程序一打开就要求必须登陆的方式去获取用户信息,因此我们也不能去校验这个用户是否有权限访问这个接口,但是有的接口又不能让任何人随便去看或者被随意采集。 基于token(令牌)的用户认证 用户输入其登录信息 服务器验证信息是否正确,并返回已签名的token token储在客户端,例如存在local storage或cookie中 之后的HTTP请求都将token添加到请求头里 服务器解码JWT,并且如果令牌有效,则接受请求 一旦用户注销,令牌将在客户端被销毁,不需要与服务器进行交互一个关键是,令牌是无状态的。后端服务器不需要保存令牌或当前session的记录。 关于JWT的详细介绍网上有很多,这里也就不说了,下面介绍在Koa2框架里的添加方法。 安装依赖 [代码]npm install jsonwebtoken npm install koa-jwt [代码] app.js 引用 [代码]const jwtKoa = require('koa-jwt'); [代码] 设置不需要JWT验证的目录或者文件 [代码]const secret = '设置密钥'; app.use(jwtKoa({secret}).unless({ path: ['/','\/favicon.ico',/^\demo/] })) [代码] 数组中的路径不需要通过jwt验证。 授权 小程序 wx.request 发送网络请求的 referer header 不可设置。 其格式固定为 https://servicewechat.com/{appid}/{version}/page-frame.html,其中 {appid} 为小程序的 appid,{version} 为小程序的版本号,版本号为 0 表示为开发版、体验版以及审核版本,版本号为 devtools 表示为开发者工具,其余为正式版本。 那么我们就可以根据 ctx.header 里的 referer 进行初步的限制,比如指定的 appid 才能生成令牌。 我们在生成令牌的时候可以把简单的信息加入进去,如: [代码]const userToken = { referer: refererArray[2], appid: refererArray[3], version: refererArray[4], data: '此处可传入用户的信息' } [代码] 生成令牌: [代码]const jwt = require('jsonwebtoken'); const secret = '设置密钥'; jwt.sign(userToken, secret, {expiresIn: '2h'}); [代码] expiresIn:为令牌的有效期 这样简单的JWT令牌就生成好了,再通过接口返回给小程序端。 小程序前端如何使用JWT? 很简单,在header里加入下面属性即可。 [代码]authorization: 'Bearer 获取到的令牌' [代码] JWT优点 可扩展性好 应用程序分布式部署的情况下,session需要做多机数据共享,通常可以存在数据库或者redis里面。而JWT不需要。 无状态 JWT不在服务端存储任何状态。RESTful API的原则之一是无状态,发出请求时,总会返回带有参数的响应,不会产生附加影响。用户的认证状态引入这种附加影响,这破坏了这一原则。另外JWT的载荷中可以存储一些常用信息,用于交换信息,有效地使用JWT,可以降低服务器查询数据库的次数。 JWT缺点 安全性 由于jwt的payload是使用base64编码的,并没有加密,因此jwt中不能存储敏感数据。而session的信息是存在服务端的,相对来说更安全。 性能 JWT太长。由于是无状态使用JWT,所有的数据都被放到JWT里,如果还要进行一些数据交换,那载荷会更大,经过编码之后导致jwt非常长,cookie的限制大小一般是4k,cookie很可能放不下,所以jwt一般放在local storage里面。并且用户在系统中的每一次http请求都会把jwt携带在Header里面,http请求的Header可能比Body还要大。而sessionId只是很短的一个字符串,因此使用JWT的http请求比使用session的开销大得多。 一次性 无状态是JWT的特点,但也导致了这个问题,JWT是一次性的。想修改里面的内容,就必须签发一个新的JWT。 (1)无法废弃 通过上面JWT的验证机制可以看出来,一旦签发一个 JWT,在到期之前就会始终有效,无法中途废弃。例如你在payload中存储了一些信息,当信息需要更新时,则重新签发一个JWT,但是由于旧的JWT还没过期,拿着这个旧的JWT依旧可以登录,那登录后服务端从JWT中拿到的信息就是过时的。为了解决这个问题,我们就需要在服务端部署额外的逻辑,例如设置一个黑名单,一旦签发了新的JWT,那么旧的就加入黑名单(比如存到redis里面),避免被再次使用。 (2)续签 如果你使用jwt做会话管理,传统的cookie续签方案一般都是框架自带的,session有效期30分钟,30分钟内如果有访问,有效期被刷新至30分钟。一样的道理,要改变JWT的有效时间,就要签发新的JWT。最简单的一种方式是每次请求刷新JWT,即每个http请求都返回一个新的JWT。这个方法不仅暴力不优雅,而且每次请求都要做JWT的加密解密,会带来性能问题。另一种方法是在redis中单独为每个JWT设置过期时间,每次访问时刷新JWT的过期时间。
2019-02-20 - CSS3 Animation动画的十二原则
作为前端的设计师和工程师,我们用 CSS 去做样式、定位并创建出好看的网站。我们经常用 CSS 去添加页面的运动过渡效果甚至动画,但我们经常做的不过如此。 [代码] 动效是一个有助于访客和用户理解我们设计的强有力工具。这里有些原则能最大限度地应用在我们的工作中。 迪士尼经过基础工作练习的长时间累积,在 1981 年出版的 The Illusion of Life: Disney Animation 一书中发表了动画的十二个原则 ([] (https://en.wikipedia.org/wiki/12_basic_principles_of_animation)) 。这些原则描述了动画能怎样用于让观众相信自己沉浸在现实世界中。 [代码] 在本文中,我会逐个介绍这十二个原则,并讨论它们怎样运用在网页中。你能在 Codepen 找到它们[] (https://codepen.io/collection/AxKOdY/)。 挤压和拉伸 (Squash and stretch) [图片] 这是物体存在质量且运动时质量保持不变的概念。当一个球在弹跳时,碰击到地面会变扁,恢复的时间会越来越短。 [代码] 创建对象的时候最有用的方法是参照实物,比如人、时钟和弹性球。 当它和网页元件一起工作时可能会忽略这个原则。DOM 对象不一定和实物相关,它会按需要在屏幕上缩放。例如,一个按钮会变大并变成一个信息框,或者错误信息会出现和消失。 尽管如此,挤压和伸缩效果可以为一个对象增加实物的感觉。甚至一些形状上的小变化就可以创造出细微但抢眼的效果。 HTML [代码] [代码] <h1>Principle 1: Squash and stretch</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle one"> <div class="shape"></div> <div class="surface"></div> </article> [代码] CSS [代码].one .shape { animation: one 4s infinite ease-out; } .one .surface { background: #000; height: 10em; width: 1em; position: absolute; top: calc(50% - 4em); left: calc(50% + 10em); } @keyframes one { 0%, 15% { opacity: 0; } 15%, 25% { transform: none; animation-timing-function: cubic-bezier(1,-1.92,.95,.89); width: 4em; height: 4em; top: calc(50% - 2em); left: calc(50% - 2em); opacity: 1; } 35%, 45% { transform: translateX(8em); height: 6em; width: 2em; top: calc(50% - 3em); animation-timing-function: linear; opacity: 1; } 70%, 100% { transform: translateX(8em) translateY(5em); height: 6em; width: 2em; top: calc(50% - 3em); opacity: 0; } } body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] 预备动作 (Anticipation) [图片] 运动不倾向于突然发生。在现实生活中,无论是一个球在掉到桌子前就开始滚动,或是一个人屈膝准备起跳,运动通常有着某种事先的累积。 [代码] 我们能用它去让我们的过渡动画显得更逼真。预备动作可以是一个细微的反弹,帮人们理解什么对象将在屏幕中发生变化并留下痕迹。 例如,悬停在一个元件上时可以在它变大前稍微缩小,在初始列表中添加额外的条目来介绍其它条目的移除方法。 [代码] HTML [代码]<h1>Principle 2: Anticipation</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle two"> <div class="shape"></div> <div class="surface"></div> </article> [代码] CSS [代码].two .shape { animation: two 5s infinite ease-out; transform-origin: 50% 7em; } .two .surface { background: #000; width: 8em; height: 1em; position: absolute; top: calc(50% + 4em); left: calc(50% - 3em); } @keyframes two { 0%, 15% { opacity: 0; transform: none; } 15%, 25% { opacity: 1; transform: none; animation-timing-function: cubic-bezier(.5,.05,.91,.47); } 28%, 38% { transform: translateX(-2em); } 40%, 45% { transform: translateX(-4em); } 50%, 52% { transform: translateX(-4em) rotateZ(-20deg); } 70%, 75% { transform: translateX(-4em) rotateZ(-10deg); } 78% { transform: translateX(-4em) rotateZ(-24deg); opacity: 1; } 86%, 100% { transform: translateX(-6em) translateY(4em) rotateZ(-90deg); opacity: 0; } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] 演出布局 (Staging) [图片] 演出布局是确保对象在场景中得以聚焦,让场景中的其它对象和视觉在主动画发生的地方让位。这意味着要么把主动画放到突出的位置,要么模糊其它元件来让用户专注于看他们需要看的东西。 [代码] 在网页方面,一种方法是用 model 覆盖在某些内容上。在现有页面添加一个遮罩并把那些主要关注的内容前置展示。 另一种方法是用动作。当很多对象在运动,你很难知道哪些值得关注。如果其它所有的动作停止,只留一个在运动,即使动得很微弱,这都可以让对象更容易被察觉。 [代码] 还有一种方法是做一个晃动和闪烁的按钮来简单地建议用户比如他们可能要保存文档。屏幕保持静态,所以再细微的动作也会突显出来。 HTML [代码]<h1>Principle 3: Staging</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle three"> <div class="shape a"></div> <div class="shape b"></div> <div class="shape c"></div> </article> [代码] CSS [代码].three .shape.a { transform: translateX(-12em); } .three .shape.c { transform: translateX(12em); } .three .shape.b { animation: three 5s infinite ease-out; transform-origin: 0 6em; } .three .shape.a, .three .shape.c { animation: threeb 5s infinite linear; } @keyframes three { 0%, 10% { transform: none; animation-timing-function: cubic-bezier(.57,-0.5,.43,1.53); } 26%, 30% { transform: rotateZ(-40deg); } 32.5% { transform: rotateZ(-38deg); } 35% { transform: rotateZ(-42deg); } 37.5% { transform: rotateZ(-38deg); } 40% { transform: rotateZ(-40deg); } 42.5% { transform: rotateZ(-38deg); } 45% { transform: rotateZ(-42deg); } 47.5% { transform: rotateZ(-38deg); animation-timing-function: cubic-bezier(.57,-0.5,.43,1.53); } 58%, 100% { transform: none; } } @keyframes threeb { 0%, 20% { filter: none; } 40%, 50% { filter: blur(5px); } 65%, 100% { filter: none; } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] 连续运动和姿态对应 (Straight-Ahead Action and Pose-to-Pose) [图片] 连续运动是绘制动画的每一帧,姿态对应是通常由一个 assistant 在定义一系列关键帧后填充间隔。 [代码] 大多数网页动画用的是姿态对应:关键帧之间的过渡可以通过浏览器在每个关键帧之间的插入尽可能多的帧使动画流畅。 [代码] 有一个例外是定时功能step。通过这个功能,浏览器 “steps” 可以把尽可能多的无序帧串清晰。你可以用这种方式绘制一系列图片并让浏览器按顺序显示出来,这开创了一种逐帧动画的风格。 HTML [代码]<h1>Principle 4: Straight Ahead Action and Pose to Pose</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle four"> <div class="shape a"></div> <div class="shape b"></div> </article> [代码] CSS [代码].four .shape.a { left: calc(50% - 8em); animation: four 6s infinite cubic-bezier(.57,-0.5,.43,1.53); } .four .shape.b { left: calc(50% + 8em); animation: four 6s infinite steps(1); } @keyframes four { 0%, 10% { transform: none; } 26%, 30% { transform: rotateZ(-45deg) scale(1.25); } 40% { transform: rotateZ(-45deg) translate(2em, -2em) scale(1.8); } 50%, 75% { transform: rotateZ(-45deg) scale(1.1); } 90%, 100% { transform: none; } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] 跟随和重叠动作 (Follow Through and Overlapping Action) [图片] 事情并不总在同一时间发生。当一辆车从急刹到停下,车子会向前倾、有烟从轮胎冒出来、车里的司机继续向前冲。 [代码] 这些细节是跟随和重叠动作的例子。它们在网页中能被用作帮助强调什么东西被停止,并不会被遗忘。例如一个条目可能在滑动时稍滑微远了些,但它自己会纠正到正确位置。 要创造一个重叠动作的感觉,我们可以让元件以稍微不同的速度移动到每处。这是一种在 iOS 系统的视窗 (View) 过渡中被运用得很好的方法。一些按钮和元件以不同速率运动,整体效果会比全部东西以相同速率运动要更逼真,并留出时间让访客去适当理解变化。 [代码] 在网页方面,这可能意味着让过渡或动画的效果以不同速度来运行。 HTML [代码]<h1>Principle 5: Follow Through and Overlapping Action</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle five"> <div class="shape-container"> <div class="shape"></div> </div> </article> [代码] CSS [代码].five .shape { animation: five 4s infinite cubic-bezier(.64,-0.36,.1,1); position: relative; left: auto; top: auto; } .five .shape-container { animation: five-container 4s infinite cubic-bezier(.64,-0.36,.1,2); position: absolute; left: calc(50% - 4em); top: calc(50% - 4em); } @keyframes five { 0%, 15% { opacity: 0; transform: translateX(-12em); } 15%, 25% { transform: translateX(-12em); opacity: 1; } 85%, 90% { transform: translateX(12em); opacity: 1; } 100% { transform: translateX(12em); opacity: 0; } } @keyframes five-container { 0%, 35% { transform: none; } 50%, 60% { transform: skewX(20deg); } 90%, 100% { transform: none; } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] 缓入缓出 (Slow In and Slow Out) [图片] 对象很少从静止状态一下子加速到最大速度,它们往往是逐步加速并在停止前变慢。没有加速和减速,动画感觉就像机器人。 [代码] 在 CSS 方面,缓入缓出很容易被理解,在一个动画过程中计时功能是一种描述变化速率的方式。 [代码] 使用计时功能,动画可以由慢加速 (ease-in)、由快减速 (ease-out),或者用贝塞尔曲线做出更复杂的效果。 HTML [代码]<h1>Principle 6: Slow in and Slow out</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle six"> <div class="shape a"></div> </article> [代码] CSS [代码].six .shape { animation: six 3s infinite cubic-bezier(0.5,0,0.5,1); } @keyframes six { 0%, 5% { transform: translate(-12em); } 45%, 55% { transform: translate(12em); } 95%, 100% { transform: translate(-12em); } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] 弧线运动 (Arc) [图片] 虽然对象是更逼真了,当它们遵循「缓入缓出」的时候它们很少沿直线运动——它们倾向于沿弧线运动。 我们有几种 CSS 的方式来实现弧线运动。一种是结合多个动画,比如在弹力球动画里,可以让球上下移动的同时让它右移,这时候球的显示效果就是沿弧线运动。 HTML [代码]<h1>Principle 7: Arc (1)</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle sevena"> <div class="shape-container"> <div class="shape a"></div> </div> </article> [代码] CSS [代码].sevena .shape-container { animation: move-right 6s infinite cubic-bezier(.37,.55,.49,.67); position: absolute; left: calc(50% - 4em); top: calc(50% - 4em); } .sevena .shape { animation: bounce 6s infinite linear; border-radius: 50%; position: relative; left: auto; top: auto; } @keyframes move-right { 0% { transform: translateX(-20em); opacity: 1; } 80% { opacity: 1; } 90%, 100% { transform: translateX(20em); opacity: 0; } } @keyframes bounce { 0% { transform: translateY(-8em); animation-timing-function: cubic-bezier(.51,.01,.79,.02); } 15% { transform: translateY(8em); animation-timing-function: cubic-bezier(.19,1,.7,1); } 25% { transform: translateY(-4em); animation-timing-function: cubic-bezier(.51,.01,.79,.02); } 32.5% { transform: translateY(8em); animation-timing-function: cubic-bezier(.19,1,.7,1); } 40% { transform: translateY(0em); animation-timing-function: cubic-bezier(.51,.01,.79,.02); } 45% { transform: translateY(8em); animation-timing-function: cubic-bezier(.19,1,.7,1); } 50% { transform: translateY(3em); animation-timing-function: cubic-bezier(.51,.01,.79,.02); } 56% { transform: translateY(8em); animation-timing-function: cubic-bezier(.19,1,.7,1); } 60% { transform: translateY(6em); animation-timing-function: cubic-bezier(.51,.01,.79,.02); } 64% { transform: translateY(8em); animation-timing-function: cubic-bezier(.19,1,.7,1); } 66% { transform: translateY(7.5em); animation-timing-function: cubic-bezier(.51,.01,.79,.02); } 70%, 100% { transform: translateY(8em); animation-timing-function: cubic-bezier(.19,1,.7,1); } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] [图片] 另外一种是旋转元件,我们可以设置一个在对象之外的原点来作为它的旋转中心。当我们旋转这个对象,它看上去就是沿着弧线运动。 HTML [代码]<h1>Principle 7: Arc (2)</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle sevenb"> <div class="shape a"></div> <div class="shape b"></div> </article> [代码] CSS [代码].sevenb .shape.a { animation: sevenb 3s infinite linear; top: calc(50% - 2em); left: calc(50% - 9em); transform-origin: 10em 50%; } .sevenb .shape.b { animation: sevenb 6s infinite linear reverse; background-color: yellow; width: 2em; height: 2em; left: calc(50% - 1em); top: calc(50% - 1em); } @keyframes sevenb { 100% { transform: rotateZ(360deg); } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] 次要动作 (Secondary Action) [图片] 虽然主动画正在发生,次要动作可以增强它的效果。这就好比某人在走路的时候摆动手臂和倾斜脑袋,或者弹性球弹起的时候扬起一些灰尘。 在网页方面,当主要焦点出现的时候就可以开始执行次要动作,比如拖拽一个条目到列表中间。 HTML [代码]<h1>Principle 8: Secondary Action</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle eight"> <div class="shape a"></div> <div class="shape b"></div> <div class="shape c"></div> </article> [代码] CSS [代码].eight .shape.a { transform: translateX(-6em); animation: eight-shape-a 4s cubic-bezier(.57,-0.5,.43,1.53) infinite; } .eight .shape.b { top: calc(50% + 6em); opacity: 0; animation: eight-shape-b 4s linear infinite; } .eight .shape.c { transform: translateX(6em); animation: eight-shape-c 4s cubic-bezier(.57,-0.5,.43,1.53) infinite; } @keyframes eight-shape-a { 0%, 50% { transform: translateX(-5.5em); } 70%, 100% { transform: translateX(-10em); } } @keyframes eight-shape-b { 0% { transform: none; } 20%, 30% { transform: translateY(-1.5em); opacity: 1; animation-timing-function: cubic-bezier(.57,-0.5,.43,1.53); } 32% { transform: translateY(-1.25em); opacity: 1; } 34% { transform: translateY(-1.75em); opacity: 1; } 36%, 38% { transform: translateY(-1.25em); opacity: 1; } 42%, 60% { transform: translateY(-1.5em); opacity: 1; } 75%, 100% { transform: translateY(-8em); opacity: 1; } } @keyframes eight-shape-c { 0%, 50% { transform: translateX(5.5em); } 70%, 100% { transform: translateX(10em); } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] 时间节奏 (Timing) [图片] 动画的时间节奏是需要多久去完成,它可以被用来让看起来很重的对象做很重的动画,或者用在添加字符的动画中。 [代码] 这在网页上可能只要简单调整 animation-duration 或 transition-duration 值。 [代码] 这很容易让动画消耗更多时间,但调整时间节奏可以帮动画的内容和交互方式变得更出众。 HTML [代码]<h1>Principle 9: Timing</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle nine"> <div class="shape a"></div> <div class="shape b"></div> </article> [代码] CSS [代码].nine .shape.a { animation: nine 4s infinite cubic-bezier(.93,0,.67,1.21); left: calc(50% - 12em); transform-origin: 100% 6em; } .nine .shape.b { animation: nine 2s infinite cubic-bezier(1,-0.97,.23,1.84); left: calc(50% + 2em); transform-origin: 100% 100%; } @keyframes nine { 0%, 10% { transform: translateX(0); } 40%, 60% { transform: rotateZ(90deg); } 90%, 100% { transform: translateX(0); } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] 夸张手法 (Exaggeration) [图片] 夸张手法在漫画中是最常用来为某些动作刻画吸引力和增加戏剧性的,比如一只狼试图把自己的喉咙张得更开地去咬东西可能会表现出更恐怖或者幽默的效果。 在网页中,对象可以通过上下滑动去强调和刻画吸引力,比如在填充表单的时候生动部分会比收缩和变淡的部分更突出。 HTML [代码]<h1>Principle 10: Exaggeration</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle ten"> <div class="shape"></div> </article> [代码] CSS [代码].ten .shape { animation: ten 4s infinite linear; transform-origin: 50% 8em; top: calc(50% - 6em); } @keyframes ten { 0%, 10% { transform: none; animation-timing-function: cubic-bezier(.87,-1.05,.66,1.31); } 40% { transform: rotateZ(-45deg) scale(2); animation-timing-function: cubic-bezier(.16,.54,0,1.38); } 70%, 100% { transform: rotateZ(360deg) scale(1); } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] 扎实的描绘 (Solid drawing) [图片] 当动画对象在三维中应该加倍注意确保它们遵循透视原则。因为人们习惯了生活在三维世界里,如果对象表现得与实际不符,会让它看起来很糟糕。 如今浏览器对三维变换的支持已经不错,这意味着我们可以在场景里旋转和放置三维对象,浏览器能自动控制它们的转换。 HTML [代码]<h1>Principle 11: Solid drawing</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle eleven"> <div class="shape"> <div class="container"> <span class="front"></span> <span class="back"></span> <span class="left"></span> <span class="right"></span> <span class="top"></span> <span class="bottom"></span> </div> </div> </article> [代码] CSS [代码].eleven .shape { background: none; border: none; perspective: 400px; perspective-origin: center; } .eleven .shape .container { animation: eleven 4s infinite cubic-bezier(.6,-0.44,.37,1.44); transform-style: preserve-3d; } .eleven .shape span { display: block; position: absolute; opacity: 1; width: 4em; height: 4em; border: 1em solid #fff; background: #2d97db; } .eleven .shape span.front { transform: translateZ(3em); } .eleven .shape span.back { transform: translateZ(-3em); } .eleven .shape span.left { transform: rotateY(-90deg) translateZ(-3em); } .eleven .shape span.right { transform: rotateY(-90deg) translateZ(3em); } .eleven .shape span.top { transform: rotateX(-90deg) translateZ(-3em); } .eleven .shape span.bottom { transform: rotateX(-90deg) translateZ(3em); } @keyframes eleven { 0% { opacity: 0; } 10%, 40% { transform: none; opacity: 1; } 60%, 75% { transform: rotateX(-20deg) rotateY(-45deg) translateY(4em); animation-timing-function: cubic-bezier(1,-0.05,.43,-0.16); opacity: 1; } 100% { transform: translateZ(-180em) translateX(20em); opacity: 0; } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码] 吸引力 (Appeal) [图片] 吸引力是艺术作品的特质,让我们与艺术家的想法连接起来。就像一个演员身上的魅力,是注重细节和动作相结合而打造吸引性的结果。 [代码] 精心制作网页上的动画可以打造出吸引力,例如 Stripe 这样的公司用了大量的动画去增加它们结账流程的可靠性。 [代码] HTML [代码]<h1>Principle 12: Appeal</h1> <h2><a href="https://cssanimation.rocks/principles/" target="_parent">Animation Principles for the Web</h2> <article class="principle twelve"> <div class="shape"> <div class="container"> <span class="item one"></span> <span class="item two"></span> <span class="item three"></span> <span class="item four"></span> </div> </div> </article> [代码] CSS [代码].twelve .shape { background: none; border: none; perspective: 400px; perspective-origin: center; } .twelve .shape .container { animation: show-container 8s infinite cubic-bezier(.6,-0.44,.37,1.44); transform-style: preserve-3d; width: 4em; height: 4em; border: 1em solid #fff; background: #2d97db; position: relative; } .twelve .item { background-color: #1f7bb6; position: absolute; } .twelve .item.one { animation: show-text 8s 0.1s infinite ease-out; height: 6%; width: 30%; top: 15%; left: 25%; } .twelve .item.two { animation: show-text 8s 0.2s infinite ease-out; height: 6%; width: 20%; top: 30%; left: 25%; } .twelve .item.three { animation: show-text 8s 0.3s infinite ease-out; height: 6%; width: 50%; top: 45%; left: 25%; } .twelve .item.four { animation: show-button 8s infinite cubic-bezier(.64,-0.36,.1,1.43); height: 20%; width: 40%; top: 65%; left: 30%; } @keyframes show-container { 0% { opacity: 0; transform: rotateX(-90deg); } 10% { opacity: 1; transform: none; width: 4em; height: 4em; } 15%, 90% { width: 12em; height: 12em; transform: translate(-4em, -4em); opacity: 1; } 100% { opacity: 0; transform: rotateX(-90deg); width: 4em; height: 4em; } } @keyframes show-text { 0%, 15% { transform: translateY(1em); opacity: 0; } 20%, 85% { opacity: 1; transform: none; } 88%, 100% { opacity: 0; transform: translateY(-1em); animation-timing-function: cubic-bezier(.64,-0.36,.1,1.43); } } @keyframes show-button { 0%, 25% { transform: scale(0); opacity: 0; } 35%, 80% { transform: none; opacity: 1; } 90%, 100% { opacity: 0; transform: scale(0); } } /* General styling */ body { margin: 0; background: #e9b59f; font-family: HelveticaNeue, Arial, Sans-serif; color: #fff; } h1 { position: absolute; top: 0; left: 0; right: 0; text-align: center; font-weight: 300; } h2 { font-size: 0.75em; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; } a { text-decoration: none; color: #333; } .principle { width: 100%; height: 100vh; position: relative; } .shape { background: #2d97db; border: 1em solid #fff; width: 4em; height: 4em; position: absolute; top: calc(50% - 2em); left: calc(50% - 2em); } [代码]
2019-03-21 - Service Worker学习与实践(一)——离线缓存
什么是[代码]Service Worker[代码] [代码]Service Worker[代码]本质上充当Web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步[代码]API[代码]。 [代码]Service Worker[代码]的本质是一个[代码]Web Worker[代码],它独立于[代码]JavaScript[代码]主线程,因此它不能直接访问[代码]DOM[代码],也不能直接访问[代码]window[代码]对象,但是,[代码]Service Worker[代码]可以访问[代码]navigator[代码]对象,也可以通过消息传递的方式(postMessage)与[代码]JavaScript[代码]主线程进行通信。 [代码]Service Worker[代码]是一个网络代理,它可以控制[代码]Web[代码]页面的所有网络请求。 [代码]Service Worker[代码]具有自身的生命周期,使用好[代码]Service Worker[代码]的关键是灵活控制其生命周期。 [代码]Service Worker[代码]的作用 用于浏览器缓存 实现离线[代码]Web APP[代码] 消息推送 [代码]Service Worker[代码]兼容性 [代码]Service Worker[代码]是现代浏览器的一个高级特性,它依赖于[代码]fetch API[代码]、[代码]Cache Storage[代码]、[代码]Promise[代码]等,其中,[代码]Cache[代码]提供了[代码]Request / Response[代码]对象对的存储机制,[代码]Cache Storage[代码]存储多个[代码]Cache[代码]。 [图片] 示例 在了解[代码]Service Worker[代码]的原理之前,先来看一段[代码]Service Worker[代码]的示例: [代码]self.importScripts('./serviceworker-cache-polyfill.js'); var urlsToCache = [ '/', '/index.js', '/style.css', '/favicon.ico', ]; var CACHE_NAME = 'counterxing'; self.addEventListener('install', function(event) { self.skipWaiting(); event.waitUntil( caches.open(CACHE_NAME) .then(function(cache) { return cache.addAll(urlsToCache); }) ); }); self.addEventListener('fetch', function(event) { event.respondWith( caches.match(event.request) .then(function(response) { if (response) { return response; } return fetch(event.request); }) ); }); self.addEventListener('activate', function(event) { var cacheWhitelist = ['counterxing']; event.waitUntil( caches.keys().then(function(cacheNames) { return Promise.all( cacheNames.map(function(cacheName) { if (cacheWhitelist.indexOf(cacheName) === -1) { return caches.delete(cacheName); } }) ); }) ); }); [代码] 下面开始逐段逐段地分析,揭开[代码]Service Worker[代码]的神秘面纱: [代码]polyfill[代码] 首先看第一行:[代码]self.importScripts('./serviceworker-cache-polyfill.js');[代码],这里引入了Cache API的一个polyfill,这个[代码]polyfill[代码]支持使得在较低版本的浏览器下也可以使用[代码]Cache Storage API[代码]。想要实现[代码]Service Worker[代码]的功能,一般都需要搭配[代码]Cache API[代码]代理网络请求到缓存中。 在[代码]Service Worker[代码]线程中,使用[代码]importScripts[代码]引入[代码]polyfill[代码]脚本,目的是对低版本浏览器的兼容。 [代码]Cache Resources List[代码] And [代码]Cache Name[代码] 之后,使用一个[代码]urlsToCache[代码]列表来声明需要缓存的静态资源,再使用一个变量[代码]CACHE_NAME[代码]来确定当前缓存的[代码]Cache Storage Name[代码],这里可以理解成[代码]Cache Storage[代码]是一个[代码]DB[代码],而[代码]CACHE_NAME[代码]则是[代码]DB[代码]名: [代码]var urlsToCache = [ '/', '/index.js', '/style.css', '/favicon.ico', ]; var CACHE_NAME = 'counterxing'; [代码] [代码]Lifecycle[代码] [代码]Service Worker[代码]独立于浏览器[代码]JavaScript[代码]主线程,有它自己独立的生命周期。 如果需要在网站上安装[代码]Service Worker[代码],则需要在[代码]JavaScript[代码]主线程中使用以下代码引入[代码]Service Worker[代码]。 [代码]if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').then(function(registration) { console.log('成功安装', registration.scope); }).catch(function(err) { console.log(err); }); } [代码] 此处,一定要注意[代码]sw.js[代码]文件的路径,在我的示例中,处于当前域根目录下,这意味着,[代码]Service Worker[代码]和网站是同源的,可以为当前网站的所有请求做代理,如果[代码]Service Worker[代码]被注册到[代码]/imaging/sw.js[代码]下,那只能代理[代码]/imaging[代码]下的网络请求。 可以使用[代码]Chrome[代码]控制台,查看当前页面的[代码]Service Worker[代码]情况: [图片] 安装完成后,[代码]Service Worker[代码]会经历以下生命周期: 下载([代码]download[代码]) 安装([代码]install[代码]) 激活([代码]activate[代码]) 用户首次访问[代码]Service Worker[代码]控制的网站或页面时,[代码]Service Worker[代码]会立刻被下载。之后至少每[代码]24[代码]小时它会被下载一次。它可能被更频繁地下载,不过每[代码]24[代码]小时一定会被下载一次,以避免不良脚本长时间生效。 在下载完成后,开始安装[代码]Service Worker[代码],在安装阶段,通常需要缓存一些我们预先声明的静态资源,在我们的示例中,通过[代码]urlsToCache[代码]预先声明。 在安装完成后,会开始进行激活,浏览器会尝试下载[代码]Service Worker[代码]脚本文件,下载成功后,会与前一次已缓存的[代码]Service Worker[代码]脚本文件做对比,如果与前一次的[代码]Service Worker[代码]脚本文件不同,证明[代码]Service Worker[代码]已经更新,会触发[代码]activate[代码]事件。完成激活。 如图所示,为[代码]Service Worker[代码]大致的生命周期: [图片] [代码]install[代码] 在安装完成后,尝试缓存一些静态资源: [代码]self.addEventListener('install', function(event) { self.skipWaiting(); event.waitUntil( caches.open(CACHE_NAME) .then(function(cache) { return cache.addAll(urlsToCache); }) ); }); [代码] 首先,[代码]self.skipWaiting()[代码]执行,告知浏览器直接跳过等待阶段,淘汰过期的[代码]sw.js[代码]的[代码]Service Worker[代码]脚本,直接开始尝试激活新的[代码]Service Worker[代码]。 然后使用[代码]caches.open[代码]打开一个[代码]Cache[代码],打开后,通过[代码]cache.addAll[代码]尝试缓存我们预先声明的静态文件。 监听[代码]fetch[代码],代理网络请求 页面的所有网络请求,都会通过[代码]Service Worker[代码]的[代码]fetch[代码]事件触发,[代码]Service Worker[代码]通过[代码]caches.match[代码]尝试从[代码]Cache[代码]中查找缓存,缓存如果命中,则直接返回缓存中的[代码]response[代码],否则,创建一个真实的网络请求。 [代码]self.addEventListener('fetch', function(event) { event.respondWith( caches.match(event.request) .then(function(response) { if (response) { return response; } return fetch(event.request); }) ); }); [代码] 如果我们需要在请求过程中,再向[代码]Cache Storage[代码]中添加新的缓存,可以通过[代码]cache.put[代码]方法添加,看以下例子: [代码]self.addEventListener('fetch', function(event) { event.respondWith( caches.match(event.request) .then(function(response) { // 缓存命中 if (response) { return response; } // 注意,这里必须使用clone方法克隆这个请求 // 原因是response是一个Stream,为了让浏览器跟缓存都使用这个response // 必须克隆这个response,一份到浏览器,一份到缓存中缓存。 // 只能被消费一次,想要再次消费,必须clone一次 var fetchRequest = event.request.clone(); return fetch(fetchRequest).then( function(response) { // 必须是有效请求,必须是同源响应,第三方的请求,因为不可控,最好不要缓存 if (!response || response.status !== 200 || response.type !== 'basic') { return response; } // 消费过一次,又需要再克隆一次 var responseToCache = response.clone(); caches.open(CACHE_NAME) .then(function(cache) { cache.put(event.request, responseToCache); }); return response; } ); }) ); }); [代码] 在项目中,一定要注意控制缓存,接口请求一般是不推荐缓存的。所以在我自己的项目中,并没有在这里做动态的缓存方案。 [代码]activate[代码] [代码]Service Worker[代码]总有需要更新的一天,随着版本迭代,某一天,我们需要把新版本的功能发布上线,此时需要淘汰掉旧的缓存,旧的[代码]Service Worker[代码]和[代码]Cache Storage[代码]如何淘汰呢? [代码]self.addEventListener('activate', function(event) { var cacheWhitelist = ['counterxing']; event.waitUntil( caches.keys().then(function(cacheNames) { return Promise.all( cacheNames.map(function(cacheName) { if (cacheWhitelist.indexOf(cacheName) === -1) { return caches.delete(cacheName); } }) ); }) ); }); [代码] 首先有一个白名单,白名单中的[代码]Cache[代码]是不被淘汰的。 之后通过[代码]caches.keys()[代码]拿到所有的[代码]Cache Storage[代码],把不在白名单中的[代码]Cache[代码]淘汰。 淘汰使用[代码]caches.delete()[代码]方法。它接收[代码]cacheName[代码]作为参数,删除该[代码]cacheName[代码]所有缓存。 sw-precache-webpack-plugin sw-precache-webpack-plugin是一个[代码]webpack plugin[代码],可以通过配置的方式在[代码]webpack[代码]打包时生成我们想要的[代码]sw.js[代码]的[代码]Service Worker[代码]脚本。 一个最简单的配置如下: [代码]var path = require('path'); var SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin'); const PUBLIC_PATH = 'https://www.my-project-name.com/'; // webpack needs the trailing slash for output.publicPath module.exports = { entry: { main: path.resolve(__dirname, 'src/index'), }, output: { path: path.resolve(__dirname, 'src/bundles/'), filename: '[name]-[hash].js', publicPath: PUBLIC_PATH, }, plugins: [ new SWPrecacheWebpackPlugin( { cacheId: 'my-project-name', dontCacheBustUrlsMatching: /\.\w{8}\./, filename: 'service-worker.js', minify: true, navigateFallback: PUBLIC_PATH + 'index.html', staticFileGlobsIgnorePatterns: [/\.map$/, /asset-manifest\.json$/], } ), ], } [代码] 在执行[代码]webpack[代码]打包后,会生成一个名为[代码]service-worker.js[代码]文件,用于缓存[代码]webpack[代码]打包后的静态文件。 一个最简单的示例。 [代码]Service Worker Cache[代码] VS [代码]Http Cache[代码] 对比起[代码]Http Header[代码]缓存,[代码]Service Worker[代码]配合[代码]Cache Storage[代码]也有自己的优势: 缓存与更新并存:每次更新版本,借助[代码]Service Worker[代码]可以立马使用缓存返回,但与此同时可以发起请求,校验是否有新版本更新。 无侵入式:[代码]hash[代码]值实在是太难看了。 不易被冲掉:[代码]Http[代码]缓存容易被冲掉,也容易过期,而[代码]Cache Storage[代码]则不容易被冲掉。也没有过期时间的说法。 离线:借助[代码]Service Worker[代码]可以实现离线访问应用。 但是缺点是,由于[代码]Service Worker[代码]依赖于[代码]fetch API[代码]、依赖于[代码]Promise[代码]、[代码]Cache Storage[代码]等,兼容性不太好。 后话 本文只是简单总结了[代码]Service Worker[代码]的基本使用和使用[代码]Service Worker[代码]做客户端缓存的简单方式,然而,[代码]Service Worker[代码]的作用远不止于此,例如:借助[代码]Service Worker[代码]做离线应用、用于做网络应用的推送(可参考push-notifications)等。 甚至可以借助[代码]Service Worker[代码],对接口进行缓存,在我所在的项目中,其实并不会做的这么复杂。不过做接口缓存的好处是支持离线访问,对离线状态下也能正常访问我们的[代码]Web[代码]应用。 [代码]Cache Storage[代码]和[代码]Service Worker[代码]总是分不开的。[代码]Service Worker[代码]的最佳用法其实就是配合[代码]Cache Storage[代码]做离线缓存。借助于[代码]Service Worker[代码],可以轻松实现对网络请求的控制,对于不同的网络请求,采取不同的策略。例如对于[代码]Cache[代码]的策略,其实也是存在多种情况。例如可以优先使用网络请求,在网络请求失败时再使用缓存、亦可以同时使用缓存和网络请求,一方面检查请求,一方面有检查缓存,然后看两个谁快,就用谁。 优化方向:目前我所负责的DICOM项目,虽然还没有用上[代码]Service Worker[代码],但前面经过不断地优化迭代,通过从增加http层的缓存、无损压缩图像的替换、有损压缩图像的渐进加载、更换DICOM解压缩策略、使用indexed DB缓存CT图像、首屏可见速度已经从20多秒降低到5秒左右,内存占用从700M以上降低到250M左右。后期还会一直深挖这一块。主要方向之一就是service worker的替换,全站缓存静态资源。此外,高优先级的则是DICOM无损图像解压算法的最优选择与优化、cornerstone的jpg图像展示。 项目优化还在继续,力求极致性能和用户体验~
2019-04-11 - 如何用小程序实现类原生APP下一条无限刷体验
1.背景 如今信息流业务是各大互联网公司争先抢占的一个大面包,为了提高用户的后续消费,产品想出了各种各样的方法,例如在微视中,用户可以无限上拉出下一条视频;在知乎中,也可以无限上拉出下一条回答。这样的操作方式用户体验更好,后续消费也更多。最近几年的时间,微信小程序已经从一颗小小的萌芽成长为参天大树,形成了较大规模的生态,小程序也拥有了一个很大的流量入口。 2.demo体验 那如何才能在小程序中实现类原生APP效果的下一条无限刷体验? 这篇文章详细记录了下一条无限刷效果的实现原理,以及细节和体验优化,并将相关代码抽象成一个微信小程序代码片段,有需要的同学可查看demo源码。 线上效果请用微信扫码体验: [图片] 小程序demo体验请点击:https://developers.weixin.qq.com/s/vIfPUomP7f9a 3.实现原理 出于性能和兼容性考虑,我们尽量采用小程序官方提供的原生组件来实现下一条无限刷效果。我们发现,可以将无限上拉下一篇的文章看作一个竖向滚动的轮播图,又由于每一篇文章的内容长度高于一屏幕高度,所以需要实现文章内部可滚动,以及文章之间可以上拉和下拉切换的功能。 在多次尝试后,我们最终采用了在[代码]<swiper>[代码]组件内部嵌套一个[代码]<scroll-view>[代码]组件的方式实现,利用[代码]<swiper>[代码]组件来实现文章之间上拉和下拉切换的功能,利用[代码]<scroll-view>[代码]来实现一篇文章内部可上下滚动的功能。 所以页面的dom结构如下所示: [代码]<swiper class='scroll-swiper' circular="{{false}}" vertical="{{true}}" bindchange="bindChange" skip-hidden-item-layout="{{true}}" duration="{{500}}" easing-function="easeInCubic" > <block wx:for="{{articleData}}"> <swiper-item> <scroll-view scroll-top="0" scroll-with-animation="{{false}}" scroll-y > content </scroll-view> </swiper-item> </block> </swiper> [代码] 4.性能优化 我们知道view部分是运行在webview上的,所以前端领域的大多数优化方式都有用。例如减少代码包体积,使用分包,渲染性能优化等。下面主要讲一下渲染性能优化。 4.1 dom优化 由于页面需要无限上拉刷新,所以要在[代码]<swiper>[代码]组件中不断的增加[代码]<swiper-item>[代码],这样必然会导致页面的dom节点成倍数的增加,最后非常卡顿。 为了优化页面的dom节点,我们利用[代码]<swiper>[代码]的[代码]current[代码]和[代码]<swiper-item>[代码]的[代码]index[代码]来做优化,控制是否渲染dom节点。首先,仅当[代码]index <= current + 1[代码]时渲染[代码]<swiper-item>[代码],也就是页面中最多预先加载出下一条,而不是将接口返回的所有后续数据都渲染出来;其次,对于用户已经消费过的之前的[代码]<swiper-item>[代码],不能直接销毁dom节点,否则会导致[代码]<swiper>[代码]的[代码]current[代码]值出现错乱,但是我们可以控制是否渲染[代码]<swiper-item>[代码]内部的子节点,我们设置了仅当[代码]current <= index + 1 && index -1 <= current[代码]时才会渲染[代码]<swiper-item>[代码]中的内容,也就是仅渲染当先文章,及上一篇和下一篇的文章内容,其他文章的dom节点都被销毁了。 这样,无论用户上拉刷新了多少次,页面中最多只会渲染3篇文章的内容,避免了因为上拉次数太多导致的页面卡顿。 4.2 分页时setData的优化 setData工作原理 [图片] 小程序的视图层目前使用[代码]WebView[代码]作为渲染载体,而逻辑层是由独立的 [代码]JavascriptCore[代码] 作为运行环境。在架构上,[代码]WebView[代码] 和 [代码]JavascriptCore[代码] 都是独立的模块,并不具备数据直接共享的通道。当前,视图层和逻辑层的数据传输,实际上通过两边提供的 [代码]evaluateJavascript[代码] 所实现。即用户传输的数据,需要将其转换为字符串形式传递,同时把转换后的数据内容拼接成一份 [代码]JS[代码] 脚本,再通过执行 [代码]JS[代码] 脚本的形式传递到两边独立环境。 而 [代码]evaluateJavascript[代码] 的执行会受很多方面的影响,数据到达视图层并不是实时的。 每次 [代码]setData[代码] 的调用都是一次进程间通信过程,通信开销与 setData 的数据量正相关。 [代码]setData[代码] 会引发视图层页面内容的更新,这一耗时操作一定时间中会阻塞用户交互。 [代码]setData[代码] 是小程序开发中使用最频繁的接口,也是最容易引发性能问题的接口。 避免不当使用setData [代码]data[代码] 应仅包括与页面渲染相关的数据,其他数据可绑定在this上。使用 [代码]data[代码] 在方法间共享数据,会增加 setData 传输的数据量,。 使用 [代码]setData[代码] 传输大量数据,通讯耗时与数据正相关,页面更新延迟可能造成页面更新开销增加。仅传输页面中发生变化的数据,使用 [代码]setData[代码] 的特殊 [代码]key[代码] 实现局部更新。 避免不必要的 [代码]setData[代码],避免短时间内频繁调用 [代码]setData[代码],对连续的setData调用进行合并。不然会导致操作卡顿,交互延迟,阻塞通信,页面渲染延迟。 避免在后台页面进行 [代码]setData[代码],这样会抢占前台页面的渲染资源。可将页面切入后台后的[代码]setData[代码]调用延迟到页面重新展示时执行。 优化示例 无限上拉刷新的数据会采用分页接口的形式,分多次请求回来。在使用分页接口拉取到下一刷的数据后,我们需要调用[代码]setData[代码]将数据写进[代码]data[代码]的[代码]articleData[代码]中,这个[代码]articleData[代码]是一个数组,里面存放着所有的文章数据,数据量十分庞大,如果直接[代码]setData[代码]会增加通讯耗时和页面更新开销,导致操作卡顿,交互延迟。 为了避免这个问题,我们将[代码]articleData[代码]改进为一个二维数组,每一次[代码]setData[代码]通过分页的 [代码]cachedCount[代码]标识来实现局部更新,具体代码如下: [代码]this.setData({ [`articleData[${cachedCount}]`]: [...data], cachedCount: cachedCount + 1, }) [代码] [代码]articleData[代码]的结构如下: [图片] 4.3 体验优化 解决了操作卡顿,交互延迟等问题,我们还需要对动画和交互的体验进行优化,以达到类原生APP效果的体验。 在文章间上拉切换时,我们使用了[代码]<swiper>[代码]组件自带的动画效果,并通过设置[代码]duration[代码]和[代码]easing-function[代码]来优化滚动细节和动画。 当用户阅读文章到底部时,会提示下一篇文章的标题等信息,而在页面上拉时,由于下一篇文章的内容已经加载出来了,这样在滑动过程中会出现两个重复的标题。为了避免这种情况出现,我们通过一个占满屏幕宽高的空白[代码]<view>[代码]来将下一篇文章的内容撑出屏幕,并在滚动结束时,通过[代码]hidden="{{index !== current && index !== current + 1}}"[代码]来隐藏这个空白[代码]<view>[代码],并对这个空白[代码]<view>[代码]的高度变化增加动画,来实现下一篇文章从屏幕底部滚动到屏幕顶部的效果: [代码].fake-scroll { height: 100%; width: 100%; transition: height 0.3s cubic-bezier(0.167,0.167,0.4,1); } [代码] [图片] 而当用户想要上拉查看之前阅读过的文章时,我们需要给用户一个“下滑查看上一条”提示,所以也可以采用同上的方式,通过一个占满屏幕宽高的提示语[代码]<view>[代码]来将上一篇文章的内容撑出屏幕,并在滚动结束时,通过[代码]wx:if="{{index + 1 === current}}"[代码]来隐藏这个提示语[代码]<view>[代码],并对这个提示语[代码]<view>[代码]的透明度变化增加动画,来实现下拉时提示“下滑查看上一条”的效果: [代码].fake-previous { height: 100%; width: 100%; opacity: 0; transition: opacity 1s ease-in; } .fake-previous.show-fake-previous { opacity: 1; } [代码] 至此,这个类原生APP效果的下一条无限刷体验的需求的所有要点和细节都已实现。 记录在此,欢迎交流和讨论。 小程序demo体验请点击:https://developers.weixin.qq.com/s/vIfPUomP7f9a
2019-06-25 - 小程序10行代码实现微信头像挂红旗,国庆节个性化头像
最近朋友圈里经常有看到这样的头像 [图片] 既然这么火,大家要图又这么难,作为程序员的自己当然要自己动手实现一个。 老规矩,先看效果图 [图片] 仔细研究了下,发现实现起来并不难,核心代码只有下面10行。 [代码] wx.canvasToTempFilePath({ x: 0, y: 0, width: num, height: num, destWidth: num, destHeight: num, canvasId: 'shareImg', success: function(res) { that.setData({ prurl: res.tempFilePath }) wx.hideLoading() }, fail: function(res) { wx.hideLoading() } }) [代码] 一,首先要创建一个小程序 至于如何创建小程序,我这里就不在细讲了,我也有写过创建小程序的文章,也有路过相关的学习视频,去翻下我历史文章找找就行。 二,创建好小程序后,我们就开始来布局 布局很简单,只有下面几行代码。 [代码]<!-- 画布大小按需定制 这里我按照背景图的尺寸定的 --> <canvas canvas-id="shareImg"></canvas> <!-- 预览区域 --> <view class='preview'> <image src='{{prurl}}' mode='aspectFit'></image> <button size='mini' open-type="getUserInfo" bindgetuserinfo="shengcheng" data-k="1">生成头像1</button> <button size='mini' open-type="getUserInfo" bindgetuserinfo="shengcheng" data-k="2">生成头像2</button> <button size='mini' open-type="getUserInfo" bindgetuserinfo="shengcheng" data-k="3">生成头像3</button> <button size='mini' open-type="getUserInfo" bindgetuserinfo="shengcheng" data-k="4">生成头像4</button> <button type='primary' bindtap='save'>保存分享图</button> </view> [代码] 实现效果图如下 [图片] 三,使用canvas来画图 其实我们实现微信头像挂红旗,原理很简单,就是把头像放在下面,然后把有红旗的相框盖在头像上面 [图片] 下面就直接把核心代码贴给大家 [代码]let promise1 = new Promise(function(resolve, reject) { wx.getImageInfo({ src: "../../images/xiaoshitou.jpg", success: function(res) { console.log("promise1", res) resolve(res); } }) }); let promise2 = new Promise(function(resolve, reject) { wx.getImageInfo({ src: `../../images/head${index}.png`, success: function(res) { console.log(res) resolve(res); } }) }); Promise.all([ promise1, promise2 ]).then(res => { console.log("Promise.all", res) //主要就是计算好各个图文的位置 let num = 1125; ctx.drawImage('../../'+res[0].path, 0, 0, num, num) ctx.drawImage('../../' + res[1].path, 0, 0, num, num) ctx.stroke() ctx.draw(false, () => { wx.canvasToTempFilePath({ x: 0, y: 0, width: num, height: num, destWidth: num, destHeight: num, canvasId: 'shareImg', success: function(res) { that.setData({ prurl: res.tempFilePath }) wx.hideLoading() }, fail: function(res) { wx.hideLoading() } }) }) }) [代码] 来看下画出来的效果图 [图片] 四,头像加红旗画好以后,我们就要想办法把图片保存到本地了 [图片] 保存图片的代码也很简单。 [代码]save: function() { var that = this wx.saveImageToPhotosAlbum({ filePath: that.data.prurl, success(res) { wx.showModal({ content: '图片已保存到相册,赶紧晒一下吧~', showCancel: false, confirmText: '好哒', confirmColor: '#72B9C3', success: function(res) { if (res.confirm) { console.log('用户点击确定'); } } }) } }) } [代码] 来看下保存后的效果图 [图片] 到这里,我的微信头像就成功的加上了小红旗了。 [图片] 源码我也已经给大家准备好了,有需要的同学在文末留言即可。 [图片] 后面我准备录制一门视频课程出来,来详细教大家实现这个功能,敬请关注。
2019-09-26 - 关于接入内容安全API依然被拒解决办法
近期绝大部分小程序收到内容安全能力警告通知如下 [图片] 官方回复一般为: 开发者你好,近期平台收到较多投诉及通报,反馈部分小程序未做好内容审核,存在安全隐患。为完善小程序内容安全生态,近日平台给部分类目陆续下发了相关的隐患提醒,请开发者结合自己实际情况,如未完善内容审核措施的,请尽快完善,可接入平台提供的内容安全接口或其他内容安全能力;如已有相关内容审核措施,请继续加强内容审核力度,做好内容安全掌控。这里并不强制必须要接入微信官方提供的安全接口。 不少开发者认为仅仅接入内容安全API就足够了吗? 其实微信官方提供的内容安全接口并不是万能的,也可能存在错报或不能正确识别的情况,特别是图片安全接口,违法违规信息可以任意展示,同时微信官方也没有强制必须要求接入微信官方提供的安全接口, > 官方内容传播控制策略建议(摘自:珊瑚内容安全用手[小程序]) 1、用户发布内容在未经审核前,建议限制内容传播范围,包括限制转发、访问,或不得在搜索结果中曝光。 2、运营者可根据文字、图片等鉴别能力的检测返回结果,自行对存在风险的内容及时删除、拦截处理。 3、对达到一定访问量或短时间转发次数异常的内容,建议运营者安排人工进行二次审核,及时发现有害内容。 4、对于恶意发布有害内容的用户账号,运营者可作限制使用、封停等处置,从账号维度强化内容管理。 5、对于封停账号,运营者可采取策略限制恶意用户再次使用产品。 所以仅仅接入内容安全API仍然是有相当一部分小程序被下架,但又不知道如何整改 根据我们自己的审核经验,总结了如下解决方法, 接入内容安全API 用户自行发布内容,需先经过内容安全API检测,此时无论通过与否,均不可展示给发布内容的用户及其他用户 再次进行人工审核 通过内容安全API检测后,因为内容安全API并不是能过滤所有有害信息,所以需要再次进行人工审核,审核无误后进行信息展示 说起来很简单,就是加上人工审核这一个环节,并在小程序代码上传时的项目备注里加上:已接入内容安全API,并有人工审核机制 有人可能会说人工审核这样的话效率不是大打折扣了吗?但我认为在大环境下这种牺牲是值得的,希望能帮助到大家
2020-05-07 - 使用 MobX 来管理小程序的跨页面数据
在小程序中,常常有些数据需要在几个页面或组件中共享。对于这样的数据,在 web 开发中,有些朋友使用过 redux 、 vuex 之类的 状态管理 框架。在小程序开发中,也有不少朋友喜欢用 MobX ,说明这类框架在实际开发中非常实用。 小程序团队近期也开源了 MobX 的辅助模块,使用 MobX 也更加方便。那么,在这篇文章中就来介绍一下 MobX 在小程序中的一个简单用例! 在小程序中引入 MobX 在小程序项目中,可以通过 npm 的方式引入 MobX 。如果你还没有在小程序中使用过 npm ,那先在小程序目录中执行命令: [代码]npm init -y [代码] 引入 MobX : [代码]npm install --save mobx-miniprogram mobx-miniprogram-bindings [代码] (这里用到了 mobx-miniprogram-bindings 模块,模块说明在这里: https://developers.weixin.qq.com/miniprogram/dev/extended/functional/mobx.html 。) npm 命令执行完后,记得在开发者工具的项目中点一下菜单栏中的 [代码]工具[代码] - [代码]构建 npm[代码] 。 MobX 有什么用呢? 试想这样一个场景:制作一个天气预报资讯小程序,首页是列表,点击列表中的项目可以进入到详情页。 首页如下: [图片] 详情页如下: [图片] 每次进入首页时,需要使用 [代码]wx.request[代码] 获取天气列表数据,之后将数据使用 setData 应用到界面上。进入详情页之后,再次获取指定日期的天气详情数据,展示在详情页中。 这样做的坏处是,进入了详情页之后需要再次通过网络获取一次数据,等待网络返回后才能将数据展示出来。 事实上,可以在首页获取天气列表数据时,就一并将所有的天气详情数据一同获取回来,存放在一个 数据仓库 中,需要的时候从仓库中取出来就可以了。这样,只需要进入首页时获取一次网络数据就可以了。 MobX 可以帮助我们很方便地建立数据仓库。接下来就讲解一下具体怎么建立和使用 MobX 数据仓库。 建立数据仓库 数据仓库通常专门写在一个独立的 js 文件中。 [代码]import { observable, action } from 'mobx-miniprogram' // 数据仓库 export const store = observable({ list: [], // 天气数据(包含列表和详情) // 设置天气列表,从网络上获取到数据之后调用 setList: action(function (list) { this.list = list }), }) [代码] 在上面数据仓库中,包含有数据 [代码]list[代码] (即天气数据),还包括了一个名为 [代码]setList[代码] 的 action ,用于更改数据仓库中的数据。 在首页中使用数据仓库 如果需要在页面中使用数据仓库里的数据,需要调用 [代码]createStoreBindings[代码] 来将仓库中的数据绑定到页面数据中,然后就可以在页面中直接使用仓库数据了。 [代码]import { createStoreBindings } from 'mobx-miniprogram-bindings' import { store } from './store' Page({ onLoad() { // 绑定 MobX store this.storeBindings = createStoreBindings(this, { store, // 需要绑定的数据仓库 fields: ['list'], // 将 this.data.list 绑定为仓库中的 list ,即天气数据 actions: ['setList'], // 将 this.setList 绑定为仓库中的 setList action }) // 从服务器端读取数据 wx.showLoading() wx.request({ // 请求网络数据 // ... success: (data) => { wx.hideLoading() // 调用 setList action ,将数据写入 store this.setList(data) } }) }, onUnload() { // 解绑 this.storeBindings.destroyStoreBindings() }, }) [代码] 这样,可以在 wxml 中直接使用 list : [代码]<view class="item" wx:for="{{list}}" wx:key="date" data-index="{{index}}"> <!-- 这里可以使用 list 中的数据了! --> <view class="title">{{item.date}} {{item.summary}}</view> <view class="abstract">{{item.temperature}}</view> </view> [代码] 在详情页中使用数据仓库 在详情页中,同样可以使用 [代码]createStoreBindings[代码] 来将仓库中的数据绑定到页面数据中: [代码]import { createStoreBindings } from 'mobx-miniprogram-bindings' import { store } from './store' Page({ onLoad(args) { // 绑定 MobX store this.storeBindings = createStoreBindings(this, { store, // 需要绑定的数据仓库 fields: ['list'], // 将 this.data.list 绑定为仓库中的 list ,即天气数据 }) // 页面参数 `index` 表示要展示哪一条天气详情数据,将它用 setData 设置到界面上 this.setData({ index: args.index }) }, onUnload() { // 解绑 this.storeBindings.destroyStoreBindings() }, }) [代码] 这样,这个页面 wxml 中也可以直接使用 list : [代码]<view class="title">{{list[index].date}}</view> <view class="content">温度 {{list[index].temperature}}</view> <view class="content">天气 {{list[index].weather}}</view> <view class="content">空气质量 {{list[index].airQuality}}</view> <view class="content">{{list[index].details}}</view> [代码] 完整示例 完整例子可以在这个代码片段中体验: https://developers.weixin.qq.com/s/YhfvpxmN7HcV 这个就是 MobX 在小程序中最基础的玩法了。相关的 npm 模块文档可参考 mobx-miniprogram-bindings 和 mobx-miniprogram 。 MobX 在实际使用时还有很多好的实践经验,感兴趣的话,可以阅读一些其他相关的文章。
2019-11-01 - 微信开发者工具下载的 sourcemaps 怎么用。
什么是 Sourcemaps uglifyjs、bable 等工具会对 源代码 进行编译处理生成编译后的代码(下称目标代码),而 sourcemaps 就是保留了目标代码在源代码中的 位置信息 --------- 大神分割线 --------- 如何解读 Sourcemaps Sourcemaps 是一个 json [代码]{ "version": 3, "sources": ["a.js", "b.js"], // 源文件列表,这个表示是由 a.js 和 b.js 合并生成 "names": ["myFn", "test"], // 如果开启了变量名混淆,这里会保留变量名在源文件中名字信息 "sourcesContent: [], // 可选项,保存源码信息,顺序与 sources 字段对应,chrome 的 sources 面板中源码使用了这个字段的内容进行展示 "sourceRoot": "", // 源文件所在的目录信息 "file": "dist.js", // 可选,编译后的文件名 "mappings": "" // 这个是重点,是目标代码和源文件的位置的映射关系 } [代码] mappings 目标文件"行"的信息 mappings 是使用 ; 分隔的,每个部分对应目标代码的行 如: “;AAAA;AAAA,BBBB;;” 本例子目标文件有 4 行 第 0 行和第 3 行没有源文件对应信息,所以这两行是编译过程中加入的代码 目标文件的"列"信息 如: “AAAA,CAEA,CAEA;” ‘,’ 表示行内的位置信息分隔符 本例表示目标文件的这一行有三个有效的位置信息。 位置信息的第一位表示目标文件的列的 偏移 信息 本例中,表示列的信息是 ‘A’、‘C’、‘C’,对应的数字为 0、+1、+1,(vlq 编码,在线编解码工具) 注意,这个是偏移信息; 列数从 0 开始,依次累加偏移值可以算出当前的位置信息对应的真正的列 所以本例中表示的是目标文件的第 n 行中的第 0 列,第 1 列,第 2 列(没错是第 2 列) 源文件的信息 如:‘AAAA;ACAA;ADAA;’ 位置信息的第二位表示源文件的信息,本例子中是 ‘A’、‘C’、‘D’,对应数字是 0、+1、-1 如果 sourcemaps 中的 sources 字段只有一个文件的话,那么位置信息中第二位一直是 A(不需要偏移) 假设 sourcemaps 中 sources: [‘a.js’, ‘b.js’] 本例的意思是 AAAA: 目标文件第 0 行第 0 列 对应 第 0 个文件 a.js ACAA; 目标文件第 1 行第 0 列 对应 第 1 个文件 b.js ADAA; 目标文件第 2 行第 0 列 对了 第 0 个文件 a.js (偏移是 -1 又回到了 a.js) 源文件的行信息 位置信息的第三位表示源文件中的行的信息, 理解了位置偏移的概念,我们很容易理解 如:‘AACA,CACA;AACA;‘ 那么 AACA: 目标文件的第 0 行第 0 列 对应 第 0 个文件的第 1 行 CACA: 目标文件的第 0 行第 0+1 列 对应 第 0 个文件的第 1+1 行 AACA:目标文件的第 1 行第 0 列 对应 第 0 个文件的第 1 行 (注意:’;’ 后的行列偏移信息归 0) 源文件中的列信息 位置信息的第四位表示源文件中的列的信息 如:'AAAA,CAAC;' 那么 AAAA: 目标文件的第 0 行第 0 列 对应 第 0 个文件的第 0 行第 0 列 CAAC: 目标文件的第 0 行第 0+1 列 对应 第 0 个文件的第 0 行第 0+1 列 位置信息的第五位 第五位表示变量的偏移,对应 sourcemaps 中的 names 字段,表示目标文件中的变量名对应域源文件中的变量 如:’AAAA,CAACC;AAAAD;' sourcemaps 中 names 字段是 [‘a’, ‘b’] 那么 AAAA: 目标文件的第 0 行第 0 列 对应 第 0 个文件的第 0 行第 0 列,没有变量的信息 CAACC: 目标文件的第 0 行第 0+1 列 对应 第 0 个文件的第 0 行第 0+1 列,有变量信息,变量在源文件中是 ‘b’ (0+1=1) AAAAD: 目标文件的第 1 行第 0 列 对应 第 0 个文件的第 0 行第 0 列,有变量信息,变量在源文件中是 ‘a’ (1-1=0) --------- 大神分割线 --------- 怎么使用 Sourcemaps Q: 线上小程序报错,我怎么通过 sourcemaps 还原到源代码中? A: 如报错 appservice.js 1:15000, 表示目标文件第一行 第 15000 列位置报错。根据上文介绍的,通过 mappings 字段算。 Q: 不会。 A: 如果你会写代码的话,参考下边 [代码]import fs = require('fs') import {SourceMapConsumer} from 'source-map' async function originalPositionFor(line, column) { const sourceMapFilePath = '如果你不真的替换的成 sourcemaps 在硬盘中的位置,那你还是放弃自己写代码吧。 ' const sourceMapConsumer = await new SourceMapConsumer(JSON.parse(fs.readFileSync(sourceMapFilePath, 'utf8'))) return sourceMapConsumer.originalPositionFor({ line, column, }) } originalPositionFor(出错的行,出错的列) [代码] Q: 不会写代码 A: 下载最新版的开发者工具,菜单-设置-拓展设置-调试器插件 [图片] [图片] Q: 为啥都是 null? A: 每个小程序版本都应该对应一个sourcemap文件。 运营中心那里下载的 sourcemap 是对应线上最新的小程序版本。但运营中心的报错集合了多个小程序版本。拿旧小程序版本的报错信息,和最新版本的 sourcemap,是匹配不出的。开发者工具和ci 上传的时候,会提示下载对应版本的 sourcemap 信息,可以自助保存。 [图片] Q: 怎么确定有没有版本对应上 A: 下载的 sourcemap 中有个 wx 字段,标明了该 sourcemap 文件对应小程序版本号。 [图片] [图片] 前提 1.确保发生错误的小程序版本和下载回来的 sourcemap 版本是一致的。 a. 下载 sourceMap 文件,可在 mp 后台或开发者工具上传成功弹窗下载 2.确保 map 文件和发生错误的 js 文件是对应的。sourcemap 的目录和文件说明 a. APP 是主包,FULL 是整包(仅在不支持分包的低版本微信中使用),其他目录是分包 b. 每个分包下都有对应的 app-service.js.map 文件。 c. 如果是使用了按需注入特性(app.json中配置了lazyCodeLoading),那么每个分包下还会有 appservice.app.js.map(对应分包下非页面的js),和所有页面的 xxx.js.map 以上事情都确保正确之后,还是出现行列号匹配不出来的情况。那就需要进一步排查。 线上运行的小程序 sourcemap 文件是怎么生成的? 处理流程:源码 [ a.js a.js.map b.js b.js.map ] -> 开发者工具(JS转 ES5,压缩)-> 微信后台(合并 js 文件)[ appservice.app.js appservice.app.js.map]。 注意:如果源码在交给工具之前是经过了 webpack 等打包工具的处理,那源码这里需要有 map 文件。否则不需要存在 map 文件。 可以看出,map 文件经过三个步骤的处理,每个步骤都有可能导致出错,因此开发者需要先排查,是否是前两个步骤出错导致的 map 文件失效的。 如何排查前两个步骤产生的 map 文件是否有问题。 1.排查 a.js.map 文件是否有问题。 a. 可以在 a.js 的代码中写一下 throw new Error(‘test sourcemap’)。 b. 使用了 webpack 的情况下,要构建为生产环境的版本。 c. 在开发者工具模拟器中运行对应的页面,看看控制台中的报错,错误行列号是否能正常映射到源文件。 2.排查 开发者工具(JS转 ES5,压缩)步骤是否有问题。 在排查完第一步的基础上,点击预览,用微信上扫码预览,并打开调试 vConsole 功能,检查 vConsole 中是否有报错信息,检查报错信息中的行列号是否能正常映射到源文件。 如何排查 微信后台(合并 js 文件)是否有问题。 a. 一定要先排查完前两个步骤再来排查这一步,一般情况下,这一步是不会出错的。 b. 如果有问题,也只会导致 map 文件中的行号信息出现偏移。比如 Error 信息中显示报错地址是 100: 200,行号是 100。那么你可能直接用 100: 200 在 map 文件中搜索不出信息,但是如果 用 150: 200 就可以搜索出来,说明行号偏移了 50。那其他报错也可以偏移 50 后再进行搜索就找到结果。 c. 怎么排查偏移了多少?可以结合 error.message 的内容,初步判断大概错误的内容是什么。把对应的 map 文件放到这个网站上 source-map-visualization 进行搜索,找出哪些相同列号的地方。再结合 error.message 的内容进行判断。 d. 如果排查到是这一步导致的问题,请在社区上联系我们,我们会在后续版本进行修复。 依旧排查不出原因? 先整理一下按照上述步骤排查的结论,再在社区上联系我们协助
2023-02-10 - 【集合】花了 3 个月,写了 40 篇小程序文章
前言 花了3个月,一共输出 40 篇文章,这也算是一个阶段性的总结。在此做个文章分类集合,希望对大家有所帮助。 小程序前端 《专治按钮效果不明显(扩散动画效果)》 《小程序开发必备,这 5 款超实用开源插件!》 《仿抽奖助手奖品详情页面向上翻页效果》 《推荐 5 款高仿知名应用的开源项目!》 《生成海报很复杂?有它轻松搞定!》 《推荐一个自定义导航栏开源库》 《前端开发,必备的学习网站!》 《情侣券-领取动画分析》 《通过玩游戏来学习CSS》 《CSS不规范导致的布局显示问题》 《微信小程序如何引入npm包?》 《情侣券-选中卡片翻转动画》 《CSS:实现卡片洗牌效果》 《情侣券 v2.0 使用的 4 款开源组件》 小程序云开发 《使用聚合函数实现打卡排行榜》 《使用云开发做内容安全检查》 《云开发-实现分页功能》 《云开发-实现维护用户表》 《云开发-实现模糊搜索》 《云开发实战:实现订阅消息推送》 《如何优雅的调用云函数?》 《云开发实战-如何维护用户表?(优化版)》 《推荐 10 款使用云开发的开源项目》 《云开发:CloudBase CMS 实战使用指南》 小程序产品 《如何利用小程序提高10倍活动效果?》 《实战:让数据说话之自定义埋点分析》 《#小程序云开发挑战赛#-情侣券》 《小程序运营必备的 3 款官方小程序》 《小程序云开发挑战赛:情侣券 v1.1 版本迭代》 《云开发挑战赛复赛:情侣券介绍PPT》 《参加#小程序云开发挑战赛#复赛收获》 《云开发挑战赛决赛:情侣券介绍PPT》 通用知识 《如何重构?》 《如何高效学习?》 《如何看懂时序图?》 《为什么优秀的程序员都写博客?》 《我从 Android 转到 微信小程序 的思考》 最后 后续计划会写更多云开发相关的文章以及小程序基础系列学习文章。
2020-11-24 - 做个优秀的小程序 - 体验评分
随着小程序的开发迭代,慢慢的我们会更加关注小程序的质量,今天来讲讲小程序的隐藏功能 -- 体验评分。 为什么需要体验评分 我们多做一点,就可以给用户更好的体验。(窃喜) 当然,做为开发者的我们,动动鼠标点一点就能帮助我们发现问题,是不是很愉快~~ 接下来我们来看看怎么使用体验评分? 怎么使用体验评分 体验评分的能力目前开放在【微信开发者工具 - 调试器 - Audits】 操作步骤:运行体验评分 - 一顿操作 - 获取体验报告 - 一顿优化。 (优化其实是一个圈,新代码加上之后也要继续关注哦~) [图片] 体验评分实践 我们用《小程序示例》来操作一波看看效果~ 01. 运行体验评分 使用开发者工具打开小程序,调试器区域切换到 Audits 面板,就一个“运行”按钮,点它。 [图片] 02.一顿操作 然后在工具上对小程序进行操作,比如:我点开了 “接口 - 媒体 - 音频 - 播放 ”。 [图片] 03.获取体验报告 操作完之后,点击“停止”,我们就可以获取到体验报告(简单~)。 [图片] 拿到报告之后,我们就可以看到总分 98,最佳实践 80。往下拉会有扣分的实际原因。 看第一条是 “发现正在使用废弃接口”,报告已经很清楚的告诉我们使用了废弃组件 audio,我们根据报告进行优化即可。 [图片] 04.一顿优化 按照报告优化完之后,我们可以继续进行体验评分功能确认优化是否完善。这是一个有用的圈圈⚪⚪⚪ 我们来讲几个优化过程中遇到的问题,咳咳咳 存在图片没有按原图宽高比显示 [图片] 在测试预览图片的时候,发现图片被挤了,体验评分告诉我们宽高比有问题,发现是 <image> 使用了默认的 mode (scaleToFill:缩放模式,不保持纵横比缩放图片,使图片的宽高完全拉伸至填满 image 元素)。所以通过添加 mode="aspectFill" (缩放模式,保持纵横比缩放图片,只保证图片的短边能完全显示出来。)来解决宽高比的问题。 [图片] [图片] 发现固定底部的可点击组件可能不在iPhone X安全区域内 [图片] 这个问题我们用手机测试是正常的,但是体验评分给了提示,所以就来看看实现方式是不是有问题: 原有方式:通过接口监听systemInfo.model.indexOf('iPhone X') 给 view 添加专属 class 官方推荐:官方推荐的方式是用 wxss 来兼容,不一定只有 iPhone X 下面会有安全区域 [图片] 发现正在使用废弃接口 [图片] 这个问题对一些老旧代码来说很有用,比如示例很久之前写的 auto-focus,由于基本没有改动,所以代码就一直保持不变。使用体验评分的时候检测到了这个属性是废弃属性,所以我们更换了可用属性 focus 来解决问题。 [图片] 体验评分总结 使用体验评分进行小程序示例的优化,有以下优点: 可以发现代码中使用的废弃api,避免后续踩坑根据实际操作发现相关耗时久的情况,预先发现体验问题合理的视觉/交互检测,提前做好兼容资源使用检测,用合适的资源做好小程序当然,体验过程中也有不足: 开发者工具不支持预览的 组件 / API 暂不支持体验评分(听说官方已经在努力推进啦)一起体验评分 如果你也在做小程序优化,欢迎使用体验评分来优化哦~ 预祝大家都拿 100婚 !!! [图片] 体验评分文档传送门 如果你有疑问,请在下方评论区留言给binnie,㊗️大家都没有bug,✌️✌️✌️
2020-12-04 - 小程序图片压缩那些事
小程序图片压缩那些事 ~ 小程序图片压缩技术方案 1 [图片] 1 [图片] 1 非原图模式上传 [图片] 1 [图片] 1 [图片] 参考资料 本文主要整理小程序在图片压缩方面的一些资料 https://developers.weixin.qq.com/miniprogram/dev/api/media/image/wx.chooseImage.html https://developers.weixin.qq.com/miniprogram/dev/api/media/image/wx.compressImage.html
2021-01-26 - 小程序使用Grid和css变量实现瀑布流布局
前言 要实现如下瀑布流效果,动态图片,动态高度 [图片] 我知道使用JS能够实现完美瀑布流,但小程序不比web,坑点会比较多,因此我决定先使用CSS看能不能解决,最后实在不行在使用JS来实现 根据网上的教程尝试使用css的方式(column和flex)实现效果,但排列顺序都是竖排而不是横排,不符合产品需求,实现效果如下 [图片] GRID瀑布流 如此看来只剩grid这一条路了,还好成功了 基础版 下列摘自网上使用GRID实现瀑布流的实例 模板 [代码]
1/view> 2/view> 3/view> 4/view> 5/view> 6/view> ... /view> [代码] wxss [代码].waterfall { display: grid; grid-template-columns: repeat(2, 1fr); // 指定两列,自动宽度 grid-gap: 0.25em; // 横向,纵向间隔 grid-auto-flow: row dense; // 是否自动补齐空白 grid-auto-rows: 20px; // base高度,grid-row基于此运算 } .waterfall .item { width: 100%; background: #222; color: #ddd; } .waterfall .item:nth-of-type(3n+1) { grid-row: auto / span 5; } .waterfall .item:nth-of-type(3n+2) { grid-row: auto / span 6; } .waterfall .item:nth-of-type(3n+3) { grid-row: auto / span 8; } [代码] 效果 [图片] 基础版的问题 看看上面的css是如何使用grid实现 [代码].waterfall .item:nth-of-type(3n+1) { grid-row: auto / span 5; } [代码] 上述代码指定[代码]1,4,7,10...[代码]等item的高度,[代码]auto[代码]为grid自动设置该item的起始位置,[代码]span 5[代码]则指定该item的高度为[代码]grid-auto-rows * 5[代码], [代码]grid-auto-rows[代码]在CSS的设定中为20px,在源码中我做了说明,它是一个基础高度 [代码].waterfall .item:nth-of-type(3n+2) { grid-row: auto / span 6; } [代码] 上述代码指定[代码]2,8,11,14...[代码]等item的高度,[代码]auto[代码]为grid自动设置该item的起始位置,[代码]span 6[代码]则指定该item的高度为[代码]grid-auto-rows * 6[代码] [代码].waterfall .item:nth-of-type(3n+3) { grid-row: auto / span 8; } [代码] 上述代码指定[代码]3,6,12,15...[代码]等item的高度,[代码]auto[代码]为grid自动设置该item的起始位置,[代码]span 8[代码]则指定该item的高度为[代码]grid-auto-rows * 8[代码] 基础版虽然看上去基本符合我们的产品需求,但由css可以知道,它的问题是__高度固定__,但业务上我们并不确切知道每个item的高度及所包含的图片的高度。所以接下来我们要解决__动态高度__设定的问题,让每一个item都自动计算自己的高度,而不是通过CSS来手动指定 css变量登场 微信小程序swiper的自适应高度 小程序中使用css var变量,使js可以动态设置css样式属性 上面两篇文章是之前写得关于css变量的一些巧妙的用法,css变量确实能够解决很多之前很棘手的问题,此时我脑海里面迸发出了一个绝佳的IDEA 仔细观察这段css [代码].waterfall .item:nth-of-type(3n+2) { grid-row: auto / span 6; } [代码] 唯一不确定的就是[代码]6[代码],对,它应该是一个变量而不是一个恒量,它应该是一个与高度关联的比值,而我们可以通过css变量动态设置这个比值,它大概应该长这样 [代码]page{ --item-span: x // 需要使用setData设置x值 } .waterfall .item { grid-row: var(--item-span); } [代码] 考虑到需要设置每个item的高度,应该为每一个item设定独立的样式 [代码].waterfall{ --item-span-1: x; // 使用setData设置x值 --item-span-2: y // 使用setData设置y值 } .waterfall .item-1 { grid-row: var(--item-span-1); } .waterfall .item-2 { grid-row: var(--item-span-2); } [代码] 原理 到此我们就可以讲通如何实现的原理了 注意:下面的例子使用内联样式代替上面的样式设定,因为内联样式可以由JS动态输出 模板 [代码] .../view> /view> .../view> /view> /page> [代码] Page [代码]Page({ data: { waterStyle: '', items: [...] }, onReady(){ let query = wx.createSelectorQuery().in(this) query.selectAll('.waterfall .item').boundingClientRect(ret=>{ let styleStr = ''; ret.forEach((ele, ii) => { let height = ele.height let span = parseInt(height/ 20) // 20 = grid-auto-row styleStr += `--item-span-${ii}: auto / span ${span};` }) this.setData({ waterStyle: styleStr }) }) } }) // 注释一 // 所有item的css变量合集 waterStyle /* xn在onReady方法中计算得出 --item-span-1: auto/span x1; --item-span-2: auto/span x2; --item-span-3: auto/span x3; ... */ [代码] 使用内联样式而不是.item-n [代码][代码]item元素使用内联样式,因为我们不确定item的数量。 高度计算 通过query我们可以获取所有item子元素的rect属性(长宽高…),计算height属性与grid-auto-row的比值,即我们需要的设定值 waterStyle 参考上述代码的注释一(动态计算每一个item的span比值),通过setData方法设置生效(设置在父级[代码]view.waterfall[代码]上),如此grid会自动设置每一个item子元素的位置 优化 以上基本将如何使用grid实现瀑布流的原理阐述了一遍,实际代码中需要注意[代码]grid-auto-row[代码]值的设定,在我们的项目中省略了此项css属性的设置,即span比值实际是由[代码]height/grid-gap[代码]得出,反而效果更好,具体原因我也一脸懵逼,如果有知道的留言告诉我 [图片] 2020-08-15 - IOS真机测试 editor聚焦页面上推,收起键盘,页面无法恢复,怎么解决?
[图片]
2020-04-17 - 小程序图文编辑器页面制作
[图片] wxml [代码]<view class='ceng' hidden='{{!showceng}}' catchtouchmove='true'></view> <!-- editTxt-titt --> <view class='edit-txt' hidden='{{!showedittitt}}' catchtouchmove='true'> <textarea placeholder='请编辑文字' value='{{edittitt}}' bindconfirm="editedtitt" maxlength="300" /> </view> <!-- editTxt-content --> <view class='edit-txt' hidden='{{!showeditcontent}}' catchtouchmove='true'> <textarea placeholder='请编辑文字' value='{{editcontent}}' bindconfirm="editedcontent" maxlength="300" /> </view> <!-- reditTxt-content 标题输入,首编辑输入,重新编辑输入是分别使用三个不同的编辑框实现--> <view class='edit-txt' hidden='{{!showreditcontent}}' catchtouchmove='true'> <textarea placeholder='请编辑文字' value='{{reditcontent}}' bindconfirm="reditedcontent" maxlength="300" /> </view> <view class='k-bai'> <view class='txt titt' data-name='titt' bindtap='towrite'> {{titt}} <image src='/images/delet.png' class='delet-icon' data-name='titt' catchtap='ondelettitt' wx:if='{{titt!="请输入标题文字"}}'></image> </view> </view> <view class='content k-bai' wx:for="{{content}}" wx:key="" data-leixing='{{item.leixing}}' data-index='{{index}}' bindtap='redit'> <view class='txt' wx:if="{{item.leixing=='txt'}}">{{item.neirong}}</view> <image src='{{item.url}}' class='image' mode="widthFix" wx:if="{{item.leixing=='img'}}"></image> <image src='/images/delet.png' class='delet-icon' data-index='{{index}}' catchtap='ondelet' wx:if="{{content[index]!=''}}"></image> </view> <view class='add'> <image src='/images/add_06.png' bindtap='addtxt'></image> <image src='/images/add_03.png' bindtap='addimg'></image> </view> <view class='save-btn' bindtap='onsave'>上传</view> [代码] js [代码]// pages/edit/edit.js Page({ /** * 页面的初始数据 */ data: { titt: '请输入标题文字', showceng: false, showedittitt: false, showeditcontent: false, showreditcontent: false, edittitt: '', editcontent: '', reditcontent: '', target: '', content: [{ leixing: 'txt', neirong: '我爱你' }, { leixing: 'img', url: 'http://wechatpx.oss-cn-beijing.aliyuncs.com/card1_03.png' } ], }, /** * 生命周期函数--监听页面加载 */ towrite: function(e) { var that = this var target = e.currentTarget.dataset.name that.setData({ showceng: true, showedittitt: true, target: target, edittitt: '' }) }, editedtitt: function(e) { var that = this var target = that.target that.setData({ titt: e.detail.value, showceng: false, showedittitt: false, [target]: e.detail.value }) }, ondelettitt: function(e) { var that = this wx.showModal({ title: '重置标题', content: '您确定要重置标题吗?', success(res) { if (res.confirm) { that.setData({ titt: '请输入标题文字' }) } else if (res.cancel) { } }, confirmColor: '#5677fc' }) }, ondelet: function(e) { var that = this var index = e.currentTarget.dataset.index var content = that.data.content wx.showModal({ title: '删除提示', content: '您确定要删除这段编辑吗?', success(res) { if (res.confirm) { content.splice(index, 1) that.setData({ content: content }) } else if (res.cancel) {} }, confirmColor: '#5677fc' }) }, addtxt: function() { var that = this var content = that.data.content that.setData({ editcontent:'', showceng: true, showeditcontent: true }) }, editedcontent: function(e) { var that = this var input = new Object input.leixing = 'txt' input.neirong = e.detail.value var content = that.data.content content.push(input) that.setData({ content:content, showceng: false, showeditcontent: false }) }, redit:function(e){ var that = this var index = e.currentTarget.dataset.index var leixing = e.currentTarget.dataset.leixing var target = that.data.target if(leixing=='txt'){ target = "content["+index+"].neirong" that.setData({ reditcontent:'', showceng: true, showreditcontent: true, target: target }) }else if(leixing=='img'){ wx.chooseImage({ count: 1, sizeType: ['original', 'compressed'], sourceType: ['album', 'camera'], success(res) { const tempFilePaths = res.tempFilePaths target = "content[" + index + "].url" that.setData({ [target]: tempFilePaths }) } }) } }, reditedcontent: function (e) { var that = this var target = that.data.target that.setData({ [target]: e.detail.value, showceng: false, showreditcontent: false, }) }, addimg:function(){ var that = this wx.chooseImage({ count: 1, sizeType: ['original', 'compressed'], sourceType: ['album', 'camera'], success(res) { const tempFilePaths = res.tempFilePaths var input = new Object input.leixing = 'img' input.url = tempFilePaths var content = that.data.content content.push(input) that.setData({ content: content, }) } }) }, onsave:function(){ wx.showToast({ title: '上传成功!', }) }, onLoad: function(options) { }, /** * 生命周期函数--监听页面初次渲染完成 */ onReady: function() { }, /** * 生命周期函数--监听页面显示 */ onShow: function() { }, /** * 生命周期函数--监听页面隐藏 */ onHide: function() { }, /** * 生命周期函数--监听页面卸载 */ onUnload: function() { }, /** * 页面相关事件处理函数--监听用户下拉动作 */ onPullDownRefresh: function() { }, /** * 页面上拉触底事件的处理函数 */ onReachBottom: function() { }, /** * 用户点击右上角分享 */ onShareAppMessage: function() { } }) [代码] wxss [代码]/* pages/edit/edit.wxss */ page { background-color: #f1f1f1; font-size: 28rpx; padding-bottom: 150rpx; } .k-bai { background-color: #fff; padding: 30rpx; color: #666; } .txt { padding: 30rpx; border: 1rpx solid #eee; margin-top: 20rpx; position: relative; } .delet-icon { display: block; width: 50rpx; height: 50rpx; border-radius: 50rpx; position: absolute; right: -20rpx; top: -20rpx; z-index: 10; } .ceng { width: 750rpx; height: 1334rpx; position: fixed; left: 0; top: 0; z-index: 50; background-color: rgba(0, 0, 0, 0.3); } .edit-txt { width: 660rpx; background-color: #fff; padding: 30rpx; margin: 0 auto; position: fixed; left: 50%; margin-left: -360rpx; top: 220rpx; z-index: 60; } .edit-txt .save-btn { display: block; width: 450rpx; height: 80rpx; border-radius: 80rpx; background-color: #5677fc; color: #fff; text-align: center; line-height: 80rpx; position: absolute; left: 50%; margin-left: -225rpx; bottom: -40rpx; box-shadow: 0 0 10rpx rgba(0, 0, 0, 0.2); } .edit-txt textarea { width: 660rpx; height: 500rpx; } .add { position: relative; padding: 20rpx 0; width: 200rpx; margin: 0 auto; height: 70rpx; background-color: #fff; border-radius: 0 0 20rpx 20rpx; box-shadow: 0 7rpx 5rpx rgba(0, 0, 0, 0.1); } .add image { display: inline-block; width: 70rpx; height: 70rpx; } .add image:first-child { margin-right: 20rpx; margin-left: 20rpx; } .content { margin-top: 30rpx; position: relative; } .content .delet-icon { right: 12rpx; top: 25rpx; } .content .image { display: block; padding: 30rpx; border: 1rpx solid #eee; margin-top: 20rpx; position: relative; width: 630rpx; } .save-btn { width: 690rpx; height: 90rpx; text-align: center; line-height: 90rpx; background-color: #5677fc; color: #fff; font-size: 34rpx; border-radius: 90rpx; position: fixed; left: 50%; margin-left: -345rpx; bottom: 30rpx; z-index: 100; } [代码]
2019-07-29 - 微信上传图片被压缩终极解决方案
最近一直在对前期项目进行重构,遇到了之前一个悬而未解的问题,梳理下,寻找可能存在的解决方案 大家都知道微信上传图片被压缩了,但是这种情况是否能解决呢? 1 [图片] 2微信上传图片主要用到以下几个api [图片] 3 目前项目上传方案用到上述的①②两个接口,通过①选择图片,然后通过②获取图片的base64,其中在选择图片时,采用的尺寸模式为压缩。 由于该方案在减少传输和存储压力的同时,极大降低了图片的质量,导致在后续识别过程中,造成非常大的困扰。 同时,由于该方案在压缩模式选择这块,即使选择了原图,部分机型也会存在压缩的情况,并且没有一个明确的清晰的压缩策略,有时候这种压缩比非常大,同样会导致上传的身份证照片带的细节信息丢失,最后的图片甚至人眼不可识别 针对这个问题目前可供参考的解决方案是: 利用上述①③两个接口,在选择图片的时候,将图片上传到微信服务器,即通过微信的uploadImage上传到微信服务器,拿到服务器返回的文件serverId,然后通过素材管理,临时素材管理接口,根据serverId将图片下载到自己服务器,这种方案的优势在于,图片的压缩策略完全是由我们来掌控的,不管具体采用哪个压缩比,都是可以通过代码来控制。 下面简单分析下图片上传用到的几个api,传递参数以及输出相应 ①chooseImage拍照或从手机相册中选图接口 [图片] {"localIds":["wxLocalResource://6110441863775331"],"sourceType":"album","errMsg":"chooseImage:ok"} [图片] ②uploadImage上传图片接口 [图片] {"localId":"wxLocalResource://6110448596555452","serverId":"uNMAdM7ElbVX2m6bqfh77pMGD8t4u8TebDdcjOJpKidsWMKY3F0RHbQPFQp76ACB","errMsg":"uploadImage:ok"} [图片] 备注:上传图片有效期3天,可用微信多媒体接口下载图片到自己的服务器,此处获得的 serverId 即 media_id。 后端从微信服务器下拉图片 https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/Get_temporary_materials.html 属于素材管理里面的获取临时素材接口 [图片] 关于access_token如何生成,具体可以参考下面链接的文档 https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html 可在下面网址进行测试 https://mp.weixin.qq.com/debug/cgi-bin/apiinfo?t=index&type=%E5%9F%BA%E7%A1%80%E6%94%AF%E6%8C%81&form=%E5%A4%9A%E5%AA%92%E4%BD%93%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E6%8E%A5%E5%8F%A3%20/media/upload [图片] 2 [图片] 具体参考文档 https://developers.weixin.qq.com/community/develop/doc/00088493fb47182c6e27b681b54c00 目前该方案已上生产,经得起实践的检验。
2020-05-22 - 解读微信内网页跳转到APP方法,使用微信开放标签:<wx-open-launch-app>
1、功能说明 微信内置浏览器支持的<wx-open-launch-app>开放标签可以让你的H5网页拉起APP。这个是不是很神奇也是很有必要的一个功能?微信为你想好啦~实现这个功能并不复杂,代码量可以忽略为0.但是一些相关的注意事项,准入规则还是必须要明确的,否则在开发过程中容易踩到各种坑。 2、接入逻辑 2.1 设置服务号的JS安全域名,开放标签必须在这个域名或者子域名下生效详见《微信开放标签说明文档》 2.2 注册登陆微信开放平台,新建APP审核并上架成功。然后登记域名和你的APP应用绑定关系,让他们能关联起来 [图片] 3、准入门槛 看起来第二大步很简单,其实操作起来还是有点繁琐的,除去繁琐的设置外,这里有个准入门槛: 3.1 服务号门槛 服务号已认证 开放平台账号已认证 服务号与开放平台账号同主体 绑定域名和移动应用 绑定域名的要求: 域名须为当前服务号的 JS 安全域名或其子域名 域名只能同时绑定一个移动应用,因此须确保域名未被其他移动应用绑定 3.2 绑定移动应用的要求 只能绑定同一微信开放平台账号下审核通过的移动应用 3.3 绑定次数 每月可修改绑定3次 4、参考文档: 微信内网页跳转APP功能-功能介绍 | 微信开放文档 开放标签说明文档 | 微信开放文档
2020-05-07 - 用户引导组件开发(2)遮罩层与突出元素
我们一步步来吧,包括我踩过的坑我也会还原一遍,让大家一起长长见识 遮罩层 遮罩层是最没技术难度的,写个css就可以了。 .mask { z-index: 1110; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); } 按然后再加个bindtap,控制点击之后消失,感觉直接就可以用了呢。这里有铅笔画不出蜡笔的味道提供的一个代码片段: https://developers.weixin.qq.com/s/cvqEYzmM7Ffn 突出元素 这个才是难点所在。 1、确定元素位置。 我们首先要找出这个元素的轮廓,这个我是通过boundingClientRect实现的。 const query = wx.createSelectorQuery(); query.select("#gameInfo").boundingClientRect(); //gameInfo就是我们所需要突出展示的元素ID,后续可以用config传入 query.exec(function(res) { console.log(res); } 输出如下: [图片] 可以看到这个办法很好地取到了所要突出的元素的位置。 2、画出镂空遮罩。 这里我参考了这篇文章:https://www.cnblogs.com/mxdmg/p/10427605.html 文章中的方法一就不说了,又麻烦又浪费空间。 方法二我试了下,展示效果确实不错,但是也如文中所说的,点遮罩的时候会点到底下的元素,肯定会影响效果。 方法三没试,因为感觉有跟二一样的缺点。 方法四也没试,因为一看就知道很麻烦。 方法五把我导向了mask-image,想说能否用这个css属性在遮罩层上挖个洞出来。关于这方面的应用我觉得这篇文章写得挺好的:https://www.zhangxinxu.com/wordpress/2017/11/css-css3-mask-masks/。然而一一试过后,发现这个属性在小程序中好像用不了(也有可能是我哪里出错,反正就是没法生效)。 呵呵。。。以上几种方式折腾了一个晚上。 后来想想,干脆抛开幻想抛开依赖,自力更生。 不搜了,自己土办法画一个。 [图片] 我用四个view作为遮罩,框出了需要突出的元素。 <view wx:if="{{showGuide}}" class="mask" bindtap="getHidden"> <view class="mask_block" style="width:100%;top:0px;left:0px;height:{{showArea.top}}px"></view> //上 <view class="mask_block" style="width:100%;top:{{showArea.bottom}}px;left:0px;height:100%"></view> //下 <view class="mask_block" style="height:{{showArea.height}}px;top:{{showArea.top}}px;left:0px;width:{{showArea.left}}px"></view> //左 <view class="mask_block" style="height:{{showArea.height}}px;top:{{showArea.top}}px;left:{{showArea.right}}px;width:100%"></view> //右 </view> 上面的showArea就是第一步中取到的元素位置。 这样一来,遮罩中的元素突出就解决了。 3、还有一个坑 本来以为这一步搞定的时候,发现这个镂空的位置竟然还会漂移!在开发工具和真机中的位置不一样,就算同在真机中,这次展示和下次展示的位置也有可能不一样! 这TM是薛定谔的镂空框! [图片] 这就是当时的神奇漂移。 经验告诉我,会出现这种不确定性的—— 十之八九都和元素的加载是否完成有关系 请好好记住这句话,可以节省你至少一天的调试时间。 是的,坑就在于步骤一的 query.select("#gameInfo").boundingClientRect(); 什么时候取的很关键。在小程序布局完成前,你可能会取到一个偏差值,导致了后面的踩坑。 我之前是在组件生命周期(还不清楚组件生命周期的看这里)中attached环节执行,不行。发现手册中还有一个ready,感觉和page中的onReady是一个东西,然而还是不行。。。 看来还是必须等页面的onReady触发之后才能取到完全正确的值。 所以我必须在主页面的onReady事件发生后再来做这个事情。 那么如何确定onReady呢?相当于我必须能够在onReady事件中去调用用户引导组件的初始化函数。。。想来想去,他们两者之间能够互动的也只有setData了。 也就是数据监听器,不懂的点这里。 幸好按计划本来也是要传入配置数组的。于是在pages中的onReady传入配置数组,然后在组件中对配置数组进行监听。 observers: { 'config': function(config) { if (config) { this.initMask(config); } } } 如此一来,遮罩层跟元素的突出展示就算完成了。 当然,这样的遮罩层还是有些不足的,例如只支持方形镂空,如果是圆形元素看着就不那么美观。。。好在我目前没有这方面需求,以后有遇到再说吧。
2020-05-10 - 搭建websocket消息推送服务,必须要考虑的几个问题
近年,不论是正在快速增长的直播,远程教育以及IM聊天场景,还是在常规企业级系统中用到的系统提醒,对websocket的需求越来越大,对websocket的要求也越来越高。从早期对websocket的应用仅限于少部分功能和IM等特殊场景,逐步发展为追求支持高并发,百万、千万级每秒通讯的高可用websocket服务。 [图片] 面对各种新场景对websocket功能和性能越来越高的需求,不同的团队有不同的选择,有的直接使用由专业团队开发的成熟稳定的第三方websocket服务,有些则选择自建websocket服务。 作为一个具有多年websocket开发经验的老程序猿,经历了GoEasy企业级websocket服务从无到有,从小到大的过程,此文是根据过去几年在GoEasy开发过程中踩过的坑,以及为众多开发团队提供websocket服务、与众多开发者交流中的总结的一些经验和体会。 这次主要从搭建websocket服务的基本功能和特性方面做一些分享,下次有机会再从构建一个高可用websocket时要面对的高并发,海量消息,集群容灾,横向扩展,以及自动化运维等方面进更多的分享。 以下几点是个人认为在构建websocket服务时必须要考虑的一些技术特性以及能显著提高用户体验的功能,供各位同学参考: 1.建立心跳机制 心跳机制几乎是所有网络编程的第一步,经常容易被新手忽略。因为在websocket长连接中,客户端和服务端并不会一直通信,如果双方长期没有沟通则都不清楚彼此当前状态,所以需要发送一段很小的报文告诉对方“我还活着”。另外还有两个目的: 服务端检测到某个客户端迟迟没有心跳过来可以主动关闭通道,让它下线; 客户端检测到某个服务端迟迟没有响应心跳也能重连获取一个新的连接。 2.建立具有良好兼容性的客户端SDK 虽说现在主流浏览器都支持websocket,但在编码中还是会遇到浏览器兼容性问题,而且通过websocket通信的客户端早已不仅限于各种web浏览器,还包括越来越多的APP,小程序。因此就要求构建的websocket服务必须能够很友好的支持各种客户端。最好的方式就是构建一个能够兼容所有主流浏览器、小程序和APP,以及uni-app、vue、react-native等目前常见的各种前端框架的客户端SDK,这样不论公司的各个项目使用什么样的前端技术,都能够快速的集成websocket服务。 3.断网自动重连和消息补发机制 移动互联网时代,终端用户所处的网络环境多样且复杂,如用户进出电梯,出入地下室或地铁等网络不稳定的场所,或其他原因导致的网络不稳定都是很常见的场景。因此,一个可靠的websocket服务必须具备完善的断网自动重连机制。确保断网后,网络一旦恢复,能第一时间自动重新建立长连接,并且能够立即补发在网络不稳定期间发送的消息。 4.离线消息 基础的Websocket通讯从技术上来说,消息送达的前提条件就是建立起一个长连接,没有建立网络连接就来讨论通讯那是耍流氓。但是从使用者的角度上来说,随手关闭浏览器,或者将小程序、APP进程直接杀掉而导致网络连接断开的情况是随时都在发生的。然后我们下意识的期待,就是我下次打开浏览器访问网页,或者打开APP时,能够收到用户离开系统期间的所有信息。从技术上这是一个跟websocket没有多大关系的需求,但实际上却是websocket服务不可或缺的基本特性,也是一个能够极大提升用户体验的功能。 5.上下线提醒,客户端在线列表 掌握当前系统有哪些用户在线,捕捉用户上下线事件,是搭建一个企业级websocket服务,必不可少的特性,尤其是开发IM和游戏类产品。 6.支持历史消息查询 websocket服务,某种意义也是属于一个消息系统,对于历史消息的查询需求,是无法绕开的话题。比如IM系统中常见的历史消息,因此在websocket服务内部实现一个高速,可靠的消息队列机制来支持websocket服务实现历史消息的查询就是一个必须的工作。 7.消息的压缩机制 不论是为了保证消息通讯的速度和实时性,还是为了节约流量和带宽费用,或者是出于提高网卡的使用效率和增加系统的吞吐量,在通讯过程中对消息进行必要的压缩都是必不可少的。 除了需要考虑以上七点以外,笔者认为,还有几个问题也是很值得初学者积极关注的: 1.缓存和持久化 选择合适的消息缓存机制,是企业级websocket服务保证性能必须要考虑的问题。 2.异步调用 要支持大量消息通讯的高性能系统,必然推荐异步调用。若设计为同步调用,调用方就需要一直等待被调用方完成。如果一层一层的同步调用下去,所有的调用方需要相同的等待时间,调用方的资源会被大量的浪费。更糟糕的是一旦被调用方出问题,其他调用就会出现多米诺骨牌效应跟着出问题,导致故障蔓延。收到请求立即返回结果,然后再异步执行,不仅可以增加系统的吞吐量,最大的好处是让服务之间的解耦更为彻底。 3.独立于业务和标准化 尽管在一个web项目中可以同时存在常规http服务和websocket服务,尤其对性能要求不高的单应用web系统,这种方式更简单,更便于维护。但对于性能和可用性高的企业级系统或者互联网平台,更好的方式,是将websocket服务作为一个单独的微服务来进行设计,避免和常规的http服务抢占资源,导致系统性能不可控,同时也更便于横向扩展。 一个设计良好的企业级websocket服务应该是一个独立于业务系统、标准化的单独存在的技术性微服务,能够作为公司基础架构的一部分为公司的所有项目提供通讯服务。 4.幂等性和重复消息的过滤 所谓幂等性,就是一次和多次请求一个接口都应该具有同样的后果。为什么需要?对每个接口的调用都会有三种可能的结果:成功,失败和超时。对最后一种的原因很多可能是网络丢包,可能请求没有到达,也有可能返回没有收到。于是在对接口的调用时往往都会有重试机制,但重试机制很容易导致消息的重复发送,从用户层面这往往是不可接受的,因此在接口的设计时,我们就需要考虑接口的幂等性,确保同一条消息发送一次和十次都不回导致消息的重复到达。 5.支持QoS 服务质量分级 其实对于上一点消息重复的问题,行业已经有了解决方案和标准规范,对于消息到达率和重复,常用的手段就是通过消息确认的方式来确保消息到达,要求越高,意味着确认机制越复杂,成本越高。为了在成本和到达率之间有很好的平衡,通常对消息系统的服务质量(QoS)分为以下三个级别 : QoS 0(At most once):“最多发一次”,意味着发送就可以了,不需要确认机制,发送了即可,适用于要求不高的场景,可以接受一定的不到达率,成本最低。 QoS 1(At least once):“至少发一次”,意味着发送方必须明确收到接收方的确认信号,否则就会反复发,每条消息至少需要两次通信来确认到达,可以接受一些消息被重发,但成本不高 。 QoS 2(Exactly once):“确保只发一次”,意味着每条消息只能到达一次,且不允许重复到达,为了达到这个目标就需要双方至少通讯三次,成本最高。 一个完善的websocket服务面对不同的应用场景,应该能够支持选择不同等级的QoS,在成本和服务质量之间取得平衡。 最后 虽然websocket已经广泛的应用于各种系统和平台,但如果要搭建一个满足企业级或者大型互联网平台的可靠、安全稳定的websocket服务,对于没有经验的同学,在具体的技术实践过程依然是有不少的坑要踩。 对websocket服务有较高要求,选择成熟可靠的第三方websocket服务其实也是一个成本更低和高效的选择。GoEasy作为国内领先的第三方websocket消息平台,已经稳定运行了5年时间,支持千万级消息并发,除了兼容所有常见的浏览器以外,同时也兼容uni-app,各种小程序,以及vue、react-native等常见的前端框架。 希望本文能为初次搭建websocket服务的同学在思路上有所帮助和参考,也欢迎各位前辈多多批评指正,同时也希望未来有机会就更多的技术与大家进行交流。 GoEasy官网:https://www.goeasy.io/ GoEasy系列教程: 搭建websocket消息推送服务,必须要考虑的几个问题 websocket IM聊天教程-教你用GoEasy快速实现IM聊天 Websocket直播间聊天室教程-GoEasy快速实现聊天室 微信小程序使用GoEasy实现websocket实时通讯 Uniapp使用GoEasy实现websocket实时通讯 IM聊天教程:发送图片/视频/语音/表情
2020-05-21 - 如何”加快“内容安全接口imgSecCheck的云调用速度
简介 使用云开发来实现内容安全检测比较方便,并且对个人小程序来说是零成本。但是也有一些问题 问题一:通过云函数调用图片安全接口时,如果直接传入Buffer类型的图片数据,真机可能会返回-404012错误。 这个问题可以先将图片上传至云存储,再在云函数中下载图片,然后调用内容安全接口,可以避免此问题,并且实测速度较直接传入Buffer有所提升。 内容安全接口的图片大小限制为1M。 为避免出现这个问题,可以使用Canvas压缩图片。或调用获取本机图片的接口时默认选择压缩的图片,也可以规避大部分问题。 云函数的访问速度较慢(尤其是小程序启动后的第一次调用)。简单看了一下,用云函数调用文本安全时,一般是0.5-0.6s,还行。而调用图片安全时,用传入Buffer数据的方法检测一个500K的图片大约需5s;如果用先上传云存储,再调用云函数检测的方法,上传需大约0.7s,调云函数检测需2.5s,还是需要3s+,可能会影响一定的用户体验。 下面主要介绍一下如何“减少”内容安全的请求时间,这个方法在其他地方也可以用。 基本方法是在用户点击提交前进行内容安全检测,将请求结果缓存为一个promise对象,然后在用户提交时使用之前缓存的promise的[代码]then[代码]方法获取到检测结果。如果这里看明白了,后面的就不用看了,可以自己去实现了。 有些代码没有简化了,所以看起来可能有点复杂,所以可以只看文字注释。 简单的示例代码 图片安全检测 首先将图片安全检测封装为一个Promise类型的函数 [代码]async function imgSecCheck({path}){ // async关键词,声明返回Promise let cloudPath = "images/avatars/"+ path.replace(/[\/\\:]/g, "_"); // 首先上传云存储 let {fileID} = await wx.cloud.uploadFile({ cloudPath, filePath: path }); // 调用云函数 let res = await wx.cloud.callFunction({ // 这里的参数结构要根据你写的云函数来具体确定, // 该示例代码所对应的云函数代码在后文有给出 name: "openapi", data:{ name:"security.imgSecCheck", data:{ fileID } } }); return res.result; } [代码] 现在要在一个页面实现图片安全检测 在用户选择了图片后,获取到了图片的本地地址[代码]path[代码],此时调用函数提前检测,并缓存返回的promise对象 [代码]let cache_promise = imgSecCheck({ path }) [代码] 最后在用户提交图片时,使用[代码]then[代码]来获取前面缓存的结果。像这样在用户提交前进行检测,可以减少一点用户的等待时间,理想状态下用户等待的检测时间可以为0s。 [代码]cache_promise.then(res=>{ if(res.errCode==87014){ // 图片有问题,提示用户 }else{ // 图片没有问题,执行其他逻辑 } }) [代码] 文本安全检测 与图片检测类似,也可以用上面相同的方法来实现“加速” 首先将文本安全检测封装为一个Promise类型的函数 [代码]async function msgSecCheck({content}){ // async关键词,声明返回Promise let res = await wx.cloud.callFunction({ // 这里的参数结构要根据你写的云函数来具体确定, // 该示例代码所对应的云函数代码在后文有给出 name: "openapi", data:{ name:"security.msgSecCheck", data:{ content } } }); return res.result; } [代码] 可以在用户实时输入的时候,进行提前检测,于是将该函数绑定到[代码]input[代码]组件的输入事件[代码]bindinput[代码]回调上,或者绑定到输入框的失焦事件[代码]bindblur[代码]回调上,根据不同需求来选择即可。 [代码]<!--wxml--> <input bindinput="preCheck"/> <!--或者用下面这个绑定到blur回调上--> <input bindblur="preCheck"/> // js Page({ preCheck({detail:{value}}){ // 缓存检测结果 this.cache_promise = msgSecCheck({ content: value }); } }) [代码] 如果绑定到[代码]bindinput[代码],函数会在短时间被多次回调,此时需要对上面的[代码]msgSecCheck[代码]进行修饰,让其两次执行间必须间隔1s,且最后一次的回调一定执行。 可以使用限频函数[代码]throttle[代码]对其进行修饰: [代码]msgSecCheck = throttle(msgSecCheck, 1000, {} ); [代码] 这里用的的[代码]throttle[代码]函数取自微信官方的一个代码片段 [代码]function throttle(func, wait, options) { var context = void 0; var args = void 0; var result = void 0; var timeout = null; var previous = 0; if (!options) options = {}; var later = function later() { previous = options.leading === false ? 0 : Date.now(); timeout = null; result = func.apply(context, args); if (!timeout) context = args = null; }; return function () { var now = Date.now(); if (!previous && options.leading === false) previous = now; var remaining = wait - (now - previous); context = this; args = arguments; if (remaining <= 0 || remaining > wait) { clearTimeout(timeout); timeout = null; previous = now; result = func.apply(context, args); if (!timeout) context = args = null; } else if (!timeout && options.trailing !== false) { timeout = setTimeout(later, remaining); } return result; }; }; [代码] 附较完整的代码 图片安全检测 [代码]const app = getApp(); Page({ async _checkImg({path}){ // 上传图片至云存储 let cloudPath = "images/avatars/"+ path.replace(/[\/\\:]/g, "_"); let res = await wx.cloud.uploadFile({ cloudPath, filePath: path }); let {fileID} = res; // 调用云函数的图片安全检测接口,该函数返回一个Promise return app.openapi("security.imgSecCheck")({fileID}); }, checkImg({path}){ // 查看是否有缓存,有则直接返回缓存的promise if(this.promise&&path==this.tmp_path) return this.promise; // 没有则请求,并缓存请求的结果 this.promise = this._checkImg({path}); this.tmp_path = path; this.promise.then(res=>{ console.log(res); if(res.errCode==87014){ wx.showToast({ title:rich_message,icon: "none",duration:5000 }) } }); return this.promise; }, afterSelectImg({path}){ // 选择图片后 this.checkImg({path}); }, onSubmit(){ // 提交时 this.checkImg().then(res=>{ if(res.errCode==0){ //没有问题,执行其他逻辑 }else if(res.errCode==87014){ wx.showModal({ content:“图片有敏感内容”,showCancel: false }) } }); } }) [代码] 文本安全检测 [代码]const app = getApp(); // 限频函数的一个实现,来自微信官方的一个代码片段 function throttle(func, wait, options) { var context = void 0; var args = void 0; var result = void 0; var timeout = null; var previous = 0; if (!options) options = {}; var later = function later() { previous = options.leading === false ? 0 : Date.now(); timeout = null; result = func.apply(context, args); if (!timeout) context = args = null; }; return function () { var now = Date.now(); if (!previous && options.leading === false) previous = now; var remaining = wait - (now - previous); context = this; args = arguments; if (remaining <= 0 || remaining > wait) { clearTimeout(timeout); timeout = null; previous = now; result = func.apply(context, args); if (!timeout) context = args = null; } else if (!timeout && options.trailing !== false) { timeout = setTimeout(later, remaining); } return result; }; }; function msgSecCheck({content=""}){ // 判断是否有缓存 if(this.tmp_promise&&this.tmp_content==content) return this.tmp_promise; //有则直接返回 //没有缓存,则进行请求,并以promise类型缓存请求 this.tmp_promise = app.openapi("security.msgSecCheck")({content}); this.tmp_content = content; this.tmp_promise.then(res=>{ if(res.errCode==87014){ wx.showToast({ title: “有敏感词汇”,icon:"none",duration:4000 }) } }) return this.tmp_promise; } Page({ throttledMsgSecCheck: throttle( msgSecCheck, 1000, {} ),//被限频的函数,用于input事件的回调中 msgSecCheck, onInput({detail:{value}}){ // input事件的回调 this.throttledMsgSecCheck({content: value}); }, onSubmit({detail:{value}}){ wx.showLoading({ title: '检查内容中...' }) this.msgSecCheck({content: value.name}).then(res=>{ if(res.errCode==0){ wx.showLoading({ title: '正在提交' }) //执行提交逻辑 }else{ // 检测到敏感词 wx.hideLoading(); wx.showModal({ content:“有敏感词汇”, showCancel:false }) } }) } }) [代码] 本地代码的封装 [代码]/** app.js 将云调用代码简单封装了一下 **/ App({ openapi(name){ // 函数柯里化,调用时写起来方便 return ({success, fail, complete, ...data})=>{ return this.callOpenapi({name, data, success,fail, complete}); } }, callOpenapi({name, data, success, fail, complete}){ // 调用名为openapi的云函数 return this.callCloudapi({name:"openapi", data:{name, data}, success, fail, complete}); }, callCloudapi({name, data, success, fail, complete}){ return new Promise((resolve, reject)=>{ return wx.cloud.callFunction({name, data, success:res=>{ // 只需要 res.result 中的数据 success&&success(res.result); resolve(res.result); complete&&complete(res.result); }, fail:e=>{ fail&&fail(e); reject(e); complete&&complete(e); } }); }); } }); [代码] 云函数代码 [代码]// 云函数名: openapi const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV}) exports.main = async (event, context) => { console.log("调用云函数openapi, 参数event:", event); let {name, data} = event; switch (event.name) { case "security.msgSecCheck":{ try{ console.log("检查文本安全, 参数:", data); var res = await cloud.openapi.security.msgSecCheck(data); console.log("返回结果: ", res); return res; }catch(e){ return e; } } case "security.imgSecCheck":{ try{ console.log("检查图片安全, 参数:", data); if(data.media) var value = Buffer.from(data.media); else if(data.fileID){ var {fileID} = data; var res = await cloud.downloadFile({fileID}); var value = res.fileContent; } var res = await cloud.openapi.security.imgSecCheck({ media:{ contentType:"image/png", value } }); console.log("返回结果: ", res); return res; }catch(e){ return e; } } } } [代码] 官方有时会检测去小程序的内容安全接口是不是完善。为了避免接口错误导致误判,可以保留上面云函数中的console日志和一周内上传至云储存的图片留作证据
2020-07-16 - 微信小程序性能优化总结
1.关于onshow优化方案,onshow每次页面切换都会调用接口,但是有种场景下的确要用到onshow,比如说登陆成功之后刷新首页接口,但是吧,刷新完成之后你又不想在onshow调用接口,你可以在onload调用一波接口,这个时候你可以在全局app.js设置一个flag开关默认为false,登陆成功之后将全局的flag开关重置为true,当前页面onshow判断if(app.globalData.flag){/*调用接口相关操作*/,}调用完接口之后在将flag重置为false,这样就减少了onshow的每次切换接口都会请求相关没必要操作。 2.关于setData,一些没必要的参数重置可以不用setData,比如说商品列表页面跳转商品详情页面需要传入一个商品唯一标示,这个时候很多人习惯在onload里面判断if(options&&options.itemUuid){ /*itemUuid你可以理解为商品唯一标示*/ this.setData({ itemUuid }) this.getItemList() },这个时候itemUuid不需要动态更新,你可以这样做减少setData调用(不懂的setData小伙伴可以了解下下diff算法), if(options&&options.itemUuid){ /*itemUuid你可以理解为商品唯一标示*/ this.data. itemUuid = options.itemUuid this.getItemList() } 3.关于接口请求等待优化,可以做一些骨架屏、gif动画类似,微信开发者工具最新版右下角又三个点点,打开生成骨架屏会在当前文件夹生成一套wxss,wxml的相关骨架屏文件,开发者只需要在wxml和wxss引入骨架屏的样式就可以,还是蛮方便的哟 今天先到这里,改天再来更新!!!!
2020-05-12 - 新拟态Neumorphism样式的实现
流行是一种轮回,今年新拟态的风格在设计圈非常火,虽然这个设计风格存在一些问题,但个人也是蛮喜欢的,于是就把这个应用到自己的小程序里了。 一、新拟态定义和特点 [图片] 通过观察,我们发现新拟态有如下特点: 只有一个光源,左上角亮色投影,右下角深色投影常用与按钮组件和卡片上与背景对比度较弱分为两种状态,凹下去和凸出来二、代码实现 .neumorphism{ box-shadow: -7px -7px 20px 0px #fff9, -4px -4px 5px 0px #fff9, 7px 7px 20px 0px #0002, 4px 4px 5px 0px #0001; } .neumorphismin,.neumorphism:active{ box-shadow: 0px 0px 0px 0px #fff9, 0px 0px 0px 0px #fff9, 0px 0px 0px 0px #0001, 0px 0px 0px 0px #0001, inset -7px -7px 20px 0px #fff9, inset -4px -4px 5px 0px #fff9, inset 7px 7px 20px 0px #0003, inset 4px 4px 5px 0px #0001; } 在使用的过程中,只需要在原来的按钮加上class即可,点击态自动加上凹下去效果。 凸起:class ="neumorphism" 凹下:class ="neumorphismin" 实现前后对比图: [图片]
2020-05-13 - 小程序开发:缩小图片并上传
小程序中要把图片缩小再上传,可使用画布组件(canvas)缩小图片。在 wxml 代码中定义画布,位置应在屏幕外,这样就像是在后台处理图片而不显示在屏幕上。wxml 文件中的 canvas 代码: <view style="width:0px;height:0px;overflow:hidden;"> <canvas canvas-id="canvasid1" style="width:600px;height:600px;position:absolute;top:-800px;left:-800px;background-color:#cdcdcd;border:1px solid blue;"> </canvas> </view> 这段代码可处于 wxml 文件的末尾处。 要处理的图片不止一张,在缩小图片的代码中,用递归调用方式: function resize_recursion() { // canvas : resize ctx1.drawImage(arr1[i].file, 0, 0, arr1[i].widthx, arr1[i].heightx) ctx1.draw(false, res => { var lca = wx.canvasToTempFilePath({ x: 0, y: 0, width: arr1[i].widthx, height: arr1[i].heightx, canvasId: 'canvasid1', quality: 1, success: res => { arr1[i].file1 = res.tempFilePath i = i + 1 if(i==arr1.length){ lca = uploadproc() return }else{ return resize_recursion() } }, fail: function (err) { console.log(err) } }) }) // end of draw } 上传图片用到 js 的 Promise对象,提高传输效率: var arrPromise1 = new Array() for (i = 0; i < arr1.length; i++) { arrPromise1.push(new Promise(function (resolve, reject) { wx.cloud.uploadFile({ cloudPath: arr1[i].file1, filePath: arr1[i].file2, success: res => { resolve(res) }, fail: error => { reject(error) } }) })) } Promise.all(arrPromise1).then(res => { for (var i = 0; i < res.length; i++) { arr1[i].upfileId = res[i].fileID } } 图片文件最初来自交互操作:wx.chooseimage(),选定的图片存放在数组arr1中。然后读取图片的尺寸,根据大小来决定是否需要执行缩小代码。这是读取图片大小的代码,也用到 js 的 Promise对象: var arrPromise1 = new Array() for (i = 0; i < arr1.length; i++) { arr1[i] = { "file": arr1[i].path, "file1": '', "upfileId": '', "size": arr1[i].size, "width1": 0, "height1": 0, "widthx": 0, "heightx": 0, "flag": 0, "idx1": 0 } arrPromise1.push(new Promise(function (resolve, reject) { wx.getImageInfo({ src: arr1[i].file, success: res => { resolve(res) }, fail: error => { reject(error) } }) })) } Promise.all(arrPromise1).then(res => { for (i = 0; i < res.length; i++) { arr1[i].width1 = res[i].width arr1[i].height1 = res[i].height arr1[i].widthx = 200 arr1[i].heightx = 350 arr1[i].flag = lca.flagx arr1[i].idx1 = i } }, function (res) { console.log('promiseerr') }) 整个过程中,读取图片大小和上传可以用 Promise对象,缩小图片因为要用画布组件而无法使用Promise。[END]
2020-12-28 - 微信小程序列表动态分享,onShareAppMessage
应需求开发一个微信小程序功能,一个列表中每一项都放一个分享按钮,分享当前类目中的不同标题、封面、链接。 思路一 touchstart事件会比tap事件靠前,先来验证一下 js代码 Page({ data: { list : [ { id : 1, cover : '图片1链接', title: '标题1' }, { id : 2, cover : '图片2链接', title: '标题2' } ] }, onShareAppMessage(e) { console.log('onShareAppMessage'); }, touchstartHandle(e) { console.log('touchstartHandle'); } }) xml代码 <block wx:for="{{ list }}" wx:key="i"> <button type="primary" data-info="{{ item }}" bindtouchstart="touchstartHandle" open-type="share">{{ item.title }}</button> </block> 结果,第一次的执行顺序是onShareAppMessage => touchstartHandle,第二次正常touchstartHandle => onShareAppMessage。坑看来比较大 思路二 https://mp.weixin.qq.com/s/VlD1XMhyPrRaS1Uh7-mtsQ
2020-05-14 - 小程序保存图片拒绝授权解决方法
//把当前画布指定区域的内容导出生成指定大小的图片 canvasToTempFilePath({ canvasId: 'shareCanvas' }).then((res) => { //获取用户的当前设置。返回值中只会出现小程序已经向用户请求过的权限。 getSetting().then((getSettingRes) => { //判断是否有过“scope.writePhotosAlbum”这个授权 if(!getSettingRes.authSetting["scope.writePhotosAlbum"]){ //没有授权过则提前向用户发起授权请求 authorize({ scope: "scope.writePhotosAlbum", }).then((res) => { console.log("authorize",res) },(err) => { //授权失败则提示 因为官方调整有按钮才能调起openSetting这个方法 wx.showModal({ title: '提示', content: '若点击不授权,将无法使用保存图片功能', cancelText:'不授权', cancelColor:'#999', confirmText:'授权', confirmColor:'#f94218', success(res) { if (res.confirm) { //调起客户端小程序设置界面,返回用户设置的操作结果。 //设置界面只会出现小程序已经向用户请求过的权限。 openSetting() } else if (res.cancel) { console.log('用户点击取消') } } }) }) }else{ //授权过则调用保存图片到相册的方法 saveImageToPhotosAlbum({ filePath: res.tempFilePath }).then((res) => { console.log("saveImageToPhotosAlbum",res) wx.showToast({ title: "图片保存成功", icon: 'none', duration: 2000 }) this.setData({ sharehidden: true, }) }) } }) }) 提示:以上方法皆是微信原生api的异步封装
2020-05-15 - 微信小程序仿拼多多、京东实现商品详情页顶部导航滚动渐变
1.这里使用了自定义导航,然后通过监听动态来改变background的rgba透明值达到效果 2.滚动监听setData是一件很消耗性能的问题,在这里使用了事件节流,及判断了scrollTop 最终特效[图片][图片][图片][图片] 代码片段:https://developers.weixin.qq.com/s/KyKkpmmb7hhi 时间紧迫前期只做了动态实现顶部导航渐变,后面会完善动态锚点及锚点监听 原理也很简单 依据 <scroll-view scroll-into-view="{{toView}}" scroll-with-animation="true" scroll-y="true"> 既可以实现
2020-05-19 - 订阅消息的思考
本文是我的小程序开发日记的其中一篇, GitHub 原文地址 欢迎star,感谢万分! 前言 小程序的早期定位是“即用即走”或者说是“用完即走”。 但小程序的运营者却不是这么想的,希望用户尽可能的停留在小程序上,或者“多回来看看”,俗称“拉回流”。 让用户回流的关键手段就是 订阅消息,通过点击订阅消息,可直接回到小程序。 背景 早期小程序提供的是 模板消息,用户每次点击或者完成支付,都会生成一个[代码]formId[代码]或者[代码]paypay_id[代码],开发者可以通过这个[代码]formId[代码]给用户发送一次模板消息。 因此,开发者的常规做法:尽可能地在每个按钮上都封装[代码]form[代码],用以收集[代码]formId[代码];收集的[代码]formId[代码]并不会使用,而是将它们存到数据库里,在需要拉回流的时候,通过这些[代码]formId[代码]发送模板消息。 这样会存在几个问题: 用户会被莫名的骚扰(因为[代码]formId[代码]有7天的有效期) 用户收到的模板消息是无预期的(因为[代码]formId[代码]可以发任意的模板消息) 开发者在每个页面每个可点击区域都封装了[代码]form[代码],导致代码混乱 为了解决以上问题,小程序团队就采用了 订阅消息 来替换 模板消息。 小程序模板消息接口于2020年1月10日下线 订阅消息的优势 订阅消息 与 模板消息 相比较,明显的优势:用户对自己将收到的模板消息类型有一定的预期,如: [图片] 另外,对订阅消息的发送时限不做限制,即可以在任意时间给用户发送一条模板消息,而不像以前的[代码]formId[代码]有7天的有效期。 从开发者的角度看:订阅消息是使用接口调用(wx.requestSubscribeMessage),不再是以前那样,一定要用[代码]Button[代码]。对于代码维护和开发效率来说,都是利好的。 从代码维护上讲,开发者不用再层层嵌套[代码]form[代码]了,简化了许多代码;另外,以前模板消息是通过[代码]Button[代码]封装的,拜托了这层束缚之后,就不用再重置[代码]Button[代码]的样式了,对于开发效率也是有一定的帮助。 模板消息 [代码]<form bindsubmit="addFormId" report-submit> <button class="invite-btn" form-type="submit"> 邀请好友 </button> </form> [代码] [代码]Page({ data: {}, addFormId(e) { let { formId } = e.detail; // save formId } }) [代码] 订阅消息 [代码]<view class="invite-btn" bindtap="handleInvite">邀请好友</view> [代码] [代码]Page({ handleInvite() { wx.requestSubscribeMessage({ tmplIds: [''] // 订阅的模板ID }) } }) [代码] 但是,订阅消息仍有个小程序通病,有一定的兼容性,需要基础库2.4.4以上才能使用。这也就意味着,2020年1月10日模板消息下线之后,你没法招回停留在基础库2.4.4以下的用户了。 订阅消息的类型 以往的模板消息,每次发送消息需要消耗一个[代码]formId[代码],而[代码]formId[代码]有7天的有效期,因此小程序无法召回7天以前的活跃用户。 而订阅消息则提供了两种类型: 一次性订阅 长期订阅 其中,一次性订阅与以往的模板消息类似,是一次性的,唯一的差异是订阅消息没有限时;而长期订阅则是召回的利器,用户只要订阅过一次,小程序将获得给该用户发送多次消息的能力。 不过,目前长期性订阅消息仅向政务民生、医疗、交通、金融、教育等线下公共服务开放。而且个人主体的小程序没有权限申请。 开发的差异 小程序端 以往的模板消息方式,需要前端将每次收集到的[代码]formId[代码],上传至后端保存起来。现在,需要做的是,记录一下哪个用户订阅了哪些模板即可,至于订阅的次数,也是需要开发者自行保存的。 另外每次发起消息订阅,都会有弹窗出现: [图片] 用户可以勾选“总是保持以上选择,不再询问”,这样下次点击时,就直接授权订阅。 若此后希望小程序重新出现弹框,则是没有办法的。只能在设置页里取消单个订阅消息,或者关闭接收所有订阅消息: [图片] 其实就等于将这些设置转移到更深的路径上,但还是保留了用户取消订阅的权利。 如果用户关闭接收所有订阅消息,那么调用[代码]wx.requestSubscribeMessage[代码]时,会触发[代码]fail[代码],并返回如下信息: [代码]{ errCode: 20004, errMsg: 'The main switch is switched off', // 用户关闭了主开关,无法进行订阅 } [代码] 总结 订阅消息是模板消息的进阶产品,对于用户、开发者更友好,但对于小程序的运营者来说,反而并没有更大的帮助。毕竟以往[代码]formId[代码]的方式,可以用来发送任意模板消息,现在只能“特定订阅特定使用”。 因此,更多的小程序运营者会讲小程序的用户引导到公众号,这样才能更大可能地接触到用户,毕竟公众号的消息推送更不受限制。
2020-05-16 - editor富文本编辑器图片有间隙问题的处理
思路是先把整个编辑器的字体也就是父级元素设置为0大小,然后在插入子元素时把子元素的字体设置回去,为了在插入图片后方便光标定位编辑,所以在插入图片完成后再自动换行和设置字体大小 首先把编辑器字体设置为0,把 padding也设置为0[图片]然后在编辑器渲染时把字体设置进去[图片] 其后在添加完图片后自动换行[图片]
2020-04-18 - 一个类似探探的高性能卡片滑动组件
cardSwipe - 小程序卡片滑动组件 介绍 此组件是使用原生微信小程序代码开发的一款高性能的卡片滑动组件,无外部依赖,导入即可使用。其主要功能效果类似探探的卡片滑动,支持循环,新增,删除,以及替换卡片。 [图片] 用法 获取: [代码]git clone https://github.com/1esse/cardSwipe.git [代码] 相关文件介绍: /components /card /cardSwipe /pages /index 其中,components文件夹下的card组件是cardSwipe组件的抽象节点,放置卡片内容,需要调用者自己实现。而cardSwipe组件为卡片功能的具体实现。pages下的index为调用组件的页面,可供参考。 功能介绍 亮点: 支持热循环(循环与不循环动态切换),动态新增,动态删除以及动态替换卡片 卡片的wxml节点数不受卡片列表的大小影响,只等于卡片展示数,如果每次只展示三张卡片,那么卡片所代表的节点数只有三个 支持调节各种属性(滑动速度,旋转角度,卡片展示数…等等) 节点数少,可配置属性多,自由化程度高,容易嵌入各种页面且性能高 实现方式: 循环/不循环: 属性circling(true/false)控制 新增: 向卡片的循环数组中添加(不推荐新增,具体原因后面分析。硬要新增的话,如果卡片列表不大,并且需要新增多张卡片,可以直接将数据push到卡片列表中然后setData整个数组。如果是每次只增加一张卡片,推荐使用下面这种方式,以数据路径的形式赋值) [代码]this.setData({ [`cards[${cards.length}]`]: { title: `新增卡片${cards.length + 1}`, // ... } }) [代码] 删除: [代码]// removeIndex: Number,需要删除的卡片的索引,将删除的卡片的值设置为null // removed_cards: Array,收集已删除的卡片的索引,每次删除卡片都需要更新 this.setData({ [`cards[${removeIndex}]`]: null, removed_cards }) [代码] 替换: [代码]// replaceIndex:Number,需要替换的卡片的索引 // removed_cards: Array,收集已删除的卡片的索引,如果replaceIndex的卡片是已删除的卡片的话,需要将该卡片索引移出removed_cards this.setData({ [`cards[${replaceIndex}]`]: { title: `替换卡片${replaceIndex}`, // ... } // removed_cards }) [代码] 注意:删除和替换操作都需要同步removed_cards why?为什么使用removed_cards而不直接删除数组中的元素 由于小程序的机制,逻辑层和视图层的通信需要使用setData来完成。这对大数组以及对象特别不友好。如果setData的数据太多,会导致通信时间过长。又碰巧数组删除元素的操作无法通过数据路径的形式给出,这会导致每次删除都需要重新setData整张卡片列表,如果列表数据过大,将会引发性能问题。 删除的时候,如果删除的卡片索引在当前索引之前,那么当前索引所代表的卡片将会是原来的下一张,导致混乱。 保留删除元素,为卡片列表的替换以及删除提供更方便,快捷,稳定的操作。 优化 由于组件支持动态的删除以及替换,这使得我们可以以很小的卡片列表来展示超多(or 无限)的卡片 场景1:实现一个超多的卡片展示(比如1000张) 实现思路:以分页的形式每次从后台获取数据,先获取第一页和第二页的数据。在逻辑层(js)创建一个卡片列表,把第一页数据赋值给它,用于跟视图层(wxml)通信。开启循环,用户每滑动一次,将划过的卡片替换成第二页相同索引的卡片,第一页卡片全部划过,第二页的内容也已经同步替换完毕,再次请求后台,获取第三页的内容,以此类推。到最后一页的时候,当前索引为0时,关闭循环,删除最后一页替换不到的上一页剩余的卡片 场景2:实现一个无限循环的卡片 实现思路:类似场景1。不关闭循环。 为什么不建议新增卡片 新增卡片会增加卡片列表的长度,由于每次滑动都要重新计算卡片列表中所有卡片的状态,卡片列表越大,预示着每次滑动卡片需要计算状态的卡片越多,越消耗性能。在完全可以开启循环然后用替换卡片操作代替的情况下,不推荐新增卡片。建议卡片列表大小保持在10以内以保证最佳性能。 以下为卡片列表大小在每次滑动时对性能的影响(指再次渲染耗时) 注:不同手机可能结果不同,但是耗时差距的原因是一样的 耗时(ms/毫秒) 10张卡片 100张卡片 1000张卡片 再次渲染1 10 12 116 再次渲染2 12 10 87 再次渲染3 17 16 145 再次渲染4 9 16 112 再次渲染5 9 18 90 平均 11.4 14.4 110
2020-04-20 - 使用开发者工具骨架屏功能,自己做骨架屏就这么简单!!
模拟器的小眼睛旁边的有个骨架屏功能,如下图: [图片] 这个一点进去,挖,不得了啊,马上生成了当前页面的骨架屏幕代码XXX.skeleton.wxml和XXX.skeleton.wxss在同级目录下 [图片] (上图为开发者工具生成的骨架屏代码,里面的参考文档链接目前是404状态) 然后在你的主wxml里引用后写相关代码就可以啦: [图片] 1、import上一步生成的wxml。(wxss里也导入对应的wxss) 2、插入template,这个template的展示逻辑由wx:if的isLoading控制,原来的正常显示代码用wx:else显示。isLoading初始化为true,表示先显示骨架屏,等你数据请求处理完后setData isLoading为false渲染你原来的正常页面。 你就能看到没加载完成数据前的骨架屏效果啦啦啦~~
2020-04-22 - 小程序eval替代方案:eval5 1.4.0-1.4.5 发布日志
eval5是基于TypeScript编写的JavaScript解释器,100%支持ES5语法。 支持浏览器、node.js、小程序等 JavaScript 运行环境 。 项目地址: https://github.com/bplok20010/eval5 使用场景 浏览器环境中需要沙盒环境来执行JavaScript代码 浏览器环境控制代码执行时长 不支持eval/Function的JavaScript运行环境,如:微信小程序 示例 更新日志 1.4.5 修复with语句中函数调用时丢失this信息 1.4.4 修复在未使用try-catch情况下出现异常时导致下次调用evaluate时的变量声明错乱问题。 1.4.3 修复 WithStatement 中赋值不生效问题。 rootContext创建调整为:Object.create(options.rootContext),防污染。 1.4.2 新增内置对象:URIError RangeError SyntaxError ReferenceError 修复 assignment 表达式触发对象的getter方法调用 1.4.1 修复再次执行事超时机制失效问题 修复函数表达式赋值时引起的返回值错乱问题 1.4.0 解释器内部eval/Function重写 新增参数 options.rootContext 新增参数 options.globalContextInFunction 移除Interpreter.rootContext 运行原理 eval5先将源码编译得到树状结构的抽象语法树(AST)。 抽象语法树由不同的节点组成,每个节点的type标识着不同的语句或表达式,例如: 1+1的抽象语法树 [代码]{ "type": "Program", "body": [ { "type": "ExpressionStatement", "expression": { "type": "BinaryExpression", "operator": "+", "left": { "type": "Literal", "value": 1, "raw": "1" }, "right": { "type": "Literal", "value": 1, "raw": "1" } } } ], "sourceType": "script" } [代码] 根据节点type编写不同的处理模块并得到最终结果。例如:根据1+1的语法树我们可以写出一下解释器代码: [代码]function handleBinaryExpression(node) { switch( node.operator ) { case '+': return node.left.value + node.right.value; case '-': return node.left.value - node.right.value; } } [代码] [图片] 示例 在线体验 更多示例 以下是解析echarts4效果示例: [图片]
2020-04-23 - 【好文】小程序动态换肤解决方案 - 接口篇
需求说明 上一篇文章我是先通过在小程序本地预设几种主题样式,然后改变类名的方式来实现小程序的换肤功能的; 但是产品经理觉得每次改主题配置文件,都要发版,觉得太麻烦了,于是发话了:我想在管理后台有一个界面,可以让运营自行设置颜色,然后小程序这边根据运营在后台设置的色值来实现动态换肤,你们来帮我实现一下。 方案和问题 首先我们知道小程序是不能动态引入 [代码]wxss[代码] 文件的,这时候的色值字段是需要从后端接口获取之后,然后通过 [代码]style[代码] 内联的方式动态写入到需要改变色值的页面元素的标签上; 工作量之大,可想而知,因此,我们需要思考下面几个问题,然后尽可能写出可维护性,可扩展性的代码来: 页面元素组件化 —— 像[代码]按钮[代码] [代码]标签[代码] [代码]选项卡[代码] [代码]价格字体[代码] [代码]模态窗[代码]等组件抽离出来,认真考虑需要换肤的页面元素,避免二次编写; 避免内联样式直接编写,提高代码可阅读性 —— 内联编写样式会导致大量的 [代码]wxml[代码] 和 [代码]wxss[代码] 代码耦合一起,可考虑采用 [代码]wxs[代码] 编写模板字符串,动态引入,减少耦合; 避免色值字段频繁赋值 —— 页面或者组件引入 [代码]behaviors[代码] 混入色值字段,减少色值赋值代码编写; ps: 后续我会想办法尽可能通过别的手段,来通过js结合stylus预编译语言的方式,将本地篇和接口篇两种方案结合起来,即读取接口返回的色值之后,动态改变stylus文件的预设色值变量,而不是内联的方式实现小程序动态换肤 实现 接下来具体来详细详解一下我的思路和如何实现这一过程: model层: 接口会返回色值配置信息,我创建了一个 [代码]model[代码] 来存储这些信息,于是,我用单例的方式创建一个全局唯一的 [代码]model[代码] 对象 —— [代码]ViModel[代码] [代码]// viModel.js /** * 主题对象:是一个单例 * @param {*} mainColor 主色值 * @param {*} subColor 辅色值 */ function ViModel(mainColor, subColor) { if (typeof ViModel.instance == 'object') { return ViModel.instance } this.mainColor = mainColor this.subColor = subColor ViModel.instance = this return this } module.exports = { save: function(mainColor = '', subColor = '') { return new ViModel(mainColor, subColor) }, get: function() { return new ViModel() } } [代码] service层: 这是接口层,封装了读取主题样式的接口,比较简单,用 [代码]setTimeout[代码] 模拟了请求接口访问的延时,默认设置了 [代码]500[代码] ms,如果大家想要更清楚的观察 observer 监听器 的处理,可以将值调大若干倍 [代码]// service.js const getSkinSettings = () => { return new Promise((resolve, reject) => { // 模拟后端接口访问,暂时用500ms作为延时处理请求 setTimeout(() => { const resData = { code: 200, data: { mainColor: '#ff9e00', subColor: '#454545' } } // 判断状态码是否为200 if (resData.code == 200) { resolve(resData) } else { reject({ code: resData.code, message: '网络出错了' }) } }, 500) }) } module.exports = { getSkinSettings, } [代码] view层: 视图层,这只是一个内联css属性转化字符串的过程,我美其名曰视图层,正如我开篇所说的,[代码]内联[代码] 样式的编写会导致大量的 [代码]wxml[代码] 和 [代码]wxss[代码]代码冗余在一起,如果换肤的元素涉及到的 [代码]css[代码] 属性改动过多,再加上一堆的 [代码]js[代码] 的逻辑代码,后期维护代码必定是灾难性的,根本无法下手,大家可以看下我优化后的处理方式: [代码]// vi.wxs /** * css属性模板字符串构造 * * color => color属性字符串赋值构造 * background => background属性字符串赋值构造 */ var STYLE_TEMPLATE = { color: function(val) { return 'color: ' + val + '!important;' }, background: function(val) { return 'background: ' + val + '!important;' } } module.exports = { /** * 模板字符串方法 * * @param theme 主题样式对象 * @param key 需要构建内联css属性 * @param second 是否需要用到辅色 */ s: function(theme, key, second = false) { theme = theme || {} if (typeof theme === 'object') { var color = second ? theme.subColor : theme.mainColor return STYLE_TEMPLATE[key](color) } } } [代码] 注意:wxs文件的编写不能出现es6以后的语法,只能用es5及以下的语法进行编写 mixin: 上面解决完 [代码]wxml[代码] 和 [代码]wxss[代码] 代码混合的问题之后,接下来就是 [代码]js[代码] 的冗余问题了;我们获取到接口的色值信息之后,还需要将其赋值到[代码]Page[代码] 或者 [代码]Component[代码] 对象中去,也就是 [代码]this.setData({....})[代码]的方式, 才能使得页面重新 [代码]render[代码],进行换肤;</br> 微信小程序原生提供一种 [代码]Behavior[代码] 的属性,使我们避免反复 [代码]setData[代码] 操作,十分方便: [代码]// viBehaviors.js const observer = require('./observer'); const viModel = require('./viModel'); module.exports = Behavior({ data: { vi: null }, attached() { // 1. 如果接口响应过长,创建监听,回调函数中读取结果进行换肤 observer.addNotice('kNoticeVi', function(res) { this.setData({ vi: res }) }.bind(this)) // 2. 如果接口响应较快,modal有值,直接赋值,进行换肤 var modal = viModel.get() if (modal.mainColor || modal.subColor) { this.setData({ vi: modal }) } }, detached() { observer.removeNotice('kNoticeVi') } }) [代码] 到这里为止,基本的功能性代码就已经完成了,接下来我们来看一下具体的使用方法吧 具体使用 小程序启动,我们就需要去请求色值配置接口,获取主题样式,如果是需要从后台返回前台的时候也要考虑主题变动,可以在 [代码]onShow[代码] 方法处理 [代码]// app.js const { getSkinSettings } = require('./js/service'); const observer = require('./js/observer'); const viModel = require('./js/viModel'); App({ onLaunch: function () { // 页面启动,请求接口 getSkinSettings().then(res => { // 获取色值,保存到modal对象中 const { mainColor, subColor } = res.data viModel.save(mainColor, subColor) // 发送通知,变更色值 observer.postNotice('kNoticeVi', res.data) }).catch(err => { console.log(err) }) } }) [代码] 混入主题样式字段 [代码]Page[代码] 页面混入 [代码]// interface.js const viBehaviors = require('../../js/viBehaviors'); Page({ behaviors: [viBehaviors], onLoad() {} }) [代码] [代码]Component[代码] 组件混入 [代码]// wxButton.js const viBehaviors = require('../../js/viBehaviors'); Component({ behaviors: [viBehaviors], properties: { // 按钮文本 btnText: { type: String, value: '' }, // 是否为辅助按钮,更换辅色皮肤 secondary: { type: Boolean, value: false } } }) [代码] 内联样式动态换肤 [代码]Page[代码] 页面动态换肤 [代码]<view class="intro"> <view class="font mb10">正常字体</view> <view class="font font-vi mb10" style="{{_.s(vi, 'color')}}">vi色字体</view> <view class="btn main-btn mb10" style="{{_.s(vi, 'background')}}">主色按钮</view> <view class="btn sub-btn" style="{{_.s(vi, 'background', true)}}">辅色按钮</view> <!-- 按钮组件 --> <wxButton class="mb10" btnText="组件按钮(主色)" /> <wxButton class="mb10" btnText="组件按钮(辅色)" secondary /> </view> <!-- 引入模板函数 --> <wxs module="_" src="../../wxs/vi.wxs"></wxs> [代码] [代码]Component[代码] 组件动态换肤 [代码]<view class="btn" style="{{_.s(vi, 'background', secondary)}}">{{ btnText }}</view> <!-- 模板函数 --> <wxs module="_" src="../../wxs/vi.wxs" /> [代码] 再来对比一下传统的内联方式处理换肤功能的实现: [代码]<view style="color: {{ mainColor }}; background: {{ background }}">vi色字体</view> [代码] 如果后期再加入复杂的逻辑代码,开发人员后期再去阅读代码简直就是要抓狂的; 当然了,这篇文章的方案只是一定程度上简化了内联代码的编写,原理还是内联样式的注入; 我目前有一个想法,想通过某种手段在获取接口主题样式字段之后,借助 [代码]stylus[代码] 等预编译语言的变量机制,动态修改其变量,改变主题样式,方为上策; 总结:这两篇文章只是给大家提供一下小程序换肤的解决思路,文中如有不足之处,希望大家多多留言,或者去github上给我提issue,必定及时处理,谢谢大家。 效果预览 接口响应较快 —— [代码]ViModel[代码] 取值换肤 [图片] 接口响应过慢 —— [代码]observer[代码] 监听器回调取值换肤 [图片] 项目地址 项目地址:https://github.com/csonchen/wxSkin 这是本文案例的项目地址,为了方便大家浏览项目,我把编译后的wxss文件也一并上传了,大家打开就能预览,码字不易,大家如果觉得好,希望大家都去点下star哈,谢谢大家。。。
2020-04-24 - 关于腾讯云 IM 的直播大群,你想了解的都在这里
腾讯云 IM 的直播大群,又叫音视频聊天室(AVChatRoom)有以下特点: - **适用于互动直播场景,群成员人数无上限**。 - **支持针对涉黄、涉政以及不雅词的安全打击,满足安全监管需求**。 - 支持向全体在线用户推送消息(群系统通知)。 - Web 和微信小程序端支持以游客身份(即不登录)接收消息。 - 申请加群后,无需管理员审批,直接加入。 >?本文以 Web 和微信小程序端 SDK 为例,其他端 SDK 实现流程相同,操作略有差异。 ## 适用场景 #### 直播弹幕 AVChatRoom 支持弹幕、 送礼和点赞等多消息类型,轻松打造良好的直播聊天互动体验;提供弹幕内容审核能力,保证您的直播免受不雅信息干扰。 [图片] #### 网红带货 AVChatRoom 与商业直播相结合,通过提供点赞、询价、购物券等特定消息类型,帮助直播客户实现流量变现。 [图片] #### 教学白板 AVChatRoom 可提供在线课堂、文本消息、画笔轨迹等能力,轻松实现教师学生沟通、画笔轨迹保存、大班课与小班课教学等教学场景。 [图片] ## 使用限制 - 不支持撤回消息。 - 群主不可以退群,群主退群只能通过解散群组的方式。 - 不支持移除群成员。 ## 相关文档 - 群组管理 - 群组系统 - SDK 下载 - 更新日志(Web & 小程序) - SDK 手册 - 集成 SDK(Web & 小程序) - 初始化(Web & 小程序) - 登录(Web & 小程序) - 消息收发(Web & 小程序) - 群组管理(Web & 小程序) ## 使用指南 ### 步骤1:创建应用 1. 登录 即时通信 IM 控制台。 >?如果您已有应用,请记录其 SDKAppID,并执行 [步骤2](#Step2)。 >同一个腾讯云账号,最多可创建100个即时通信 IM 应用。若已有100个应用,您可以先 停用 并删除无需使用的应用后再创建新的应用。**应用删除后,该 SDKAppID 对应的所有数据和服务不可恢复,请谨慎操作。** 2. 单击【+添加新应用】。 3. 在【创建应用】对话框中输入您的应用名称,单击【确定】。创建完成后,可在控制台总览页查看新建应用的状态、业务版本、SDKAppID、创建时间以及到期时间。 4. 记录该应用的 SDKAppID 信息。 ### 步骤2:创建 AVChatRoom 您可以通过控制台创建群组,也可以通过调用 创建群组 API 创建群组。本文以通过控制台创建为例。 1. 登录 即时通信 IM 控制台,单击目标应用卡片。 2. 在左侧导航栏选择【群组管理】,单击【添加群组】。 3. 输入群名称,选填群主 ID,选择【群类型】为【互动直播聊天室】。 4. 单击【确定】,待群组创建成功后,记录其【群ID】(本文以`@TGS#aC72FIKG3`为例)。 ### 步骤3:集成 SDK 您可以通过 NPM 或 Script 集成 SDK,推荐使用 NPM 集成。本文以使用 NPM 集成为例。 - Web 项目 // Web 项目 npm install tim-js-sdk --save-dev - 小程序项目 // 微信小程序项目 npm install tim-wx-sdk --save-dev >?若同步依赖过程中出现问题,请切换 npm 源后再次重试。 // 切换 cnpm 源 npm config set registry http://r.cnpmjs.org/ ### 步骤4:创建 SDK 实例 // 创建 SDK 实例,TIM.create() 方法对于同一个 SDKAppID 只会返回同一份实例 let options = { SDKAppID: 0 // 接入时需要将0替换为您的即时通信应用的 SDKAppID } let tim = TIM.create(options) // SDK 实例通常用 tim 表示 // 设置 SDK 日志输出级别,详细分级请参考 setLogLevel 接口的说明 tim.setLogLevel(0) // 普通级别,日志量较多,接入时建议使用 tim.on(TIM.EVENT.SDK_READY, function (event) { // SDK ready 后接入侧才可以调用 sendMessage 等需要鉴权的接口,否则会提示失败! // event.name - TIM.EVENT.SDK_READY }) tim.on(TIM.EVENT.MESSAGE_RECEIVED, function(event) { // 收到推送的单聊、群聊、群提示、群系统通知的新消息,可通过遍历 event.data 获取消息列表数据并渲染到页面 // event.name - TIM.EVENT.MESSAGE_RECEIVED // event.data - 存储 Message 对象的数组 - [Message] const length = event.data.length let message for (let i = 0; i < length; i++) { // Message 实例的详细数据结构请参考 Message // 其中 type 和 payload 属性需要重点关注 // 从v2.6.0起,AVChatRoom 内的群聊消息,进群退群等群提示消息,增加了 nick(昵称) 和 avatar(头像URL) 属性,便于接入侧做体验更好的展示 // 前提您需要先调用 updateMyProfile 设置自己的 nick(昵称) 和 avatar(头像URL),请参考 updateMyProfile 接口的说明 message = event.data[i] switch (message.type) { case TIM.TYPES.MSG_TEXT: // 收到了文本消息 this._handleTextMsg(message) break case TIM.TYPES.MSG_CUSTOM // 收到了自定义消息 this._handleCustomMsg(message) break case TIM.TYPES.MSG_GRP_TIP: // 收到了群提示消息,如成员进群、群成员退群 this._handleGroupTip(message) break case TIM.TYPES.MSG_GRP_SYS_NOTICE: // 收到了群系统通知,通过 REST API 在群组中发送的系统通知请参考 在群组中发送系统通知 API this._handleGroupSystemNotice(message) break default: break } } }) _handleTextMsg(message) { // 详细数据结构请参考 TextPayload 接口的说明 console.log(message.payload.text) // 文本消息内容 } _handleCustomMsg(message) { // 详细数据结构请参考 CustomPayload 接口的说明 console.log(message.payload) } _handleGroupTip(message) { // 详细数据结构请参考 GroupTipPayload 接口的说明 switch (message.payload.operationType) { case TIM.TYPES.GRP_TIP_MBR_JOIN: // 有成员加群 break case TIM.TYPES.GRP_TIP_MBR_QUIT: // 有群成员退群 break case TIM.TYPES.GRP_TIP_MBR_KICKED_OUT: // 有群成员被踢出群 break case TIM.TYPES.GRP_TIP_MBR_SET_ADMIN: // 有群成员被设为管理员 break case TIM.TYPES.GRP_TIP_MBR_CANCELED_ADMIN: // 有群成员被撤销管理员 break case TIM.TYPES.GRP_TIP_GRP_PROFILE_UPDATED: // 群组资料变更 //从v2.6.0起支持群组自定义字段变更内容 // message.payload.newGroupProfile.groupCustomField break case TIM.TYPES.GRP_TIP_MBR_PROFILE_UPDATED: // 群成员资料变更,例如群成员被禁言 break default: break } } _handleGroupSystemNotice(message) { // 详细数据结构请参考 GroupSystemNoticePayload 接口的说明 console.log(message.payload.userDefinedField) // 用户自定义字段。使用 RESTAPI 发送群系统通知时,可在该属性值中拿到自定义通知的内容。 // 用 REST API 发送群系统通知请参考 在群组中发送系统通知 API } ### 步骤5:登录 SDK let promise = tim.login({userID: 'your userID', userSig: 'your userSig'}); promise.then(function(imResponse) { console.log(imResponse.data); // 登录成功 }).catch(function(imError) { console.warn('login error:', imError); // 登录失败的相关信息 }); ### 步骤6:设置自己的昵称和头像 2.6.2及以上版本 SDK,AVChatRoom 内的群聊消息和群提示消息(例如进群退群等),都增加了 nick(昵称) 和 avatar(头像URL) 属性,您可以调用接口 updateMyProfile 设置自己的 nick(昵称) 和 avatar(头像URL)。 // 从v2.6.0起,AVChatRoom 内的群聊消息,进群退群等群提示消息,增加了 nick(昵称) 和 avatar(头像URL) 属性,便于接入侧做体验更好的展示,前提您需要先调用 updateMyProfile 设置个人资料。 // 修改个人标配资料 let promise = tim.updateMyProfile({ nick: '我的昵称', avatar: 'http(s)://url/to/image.jpg' }); promise.then(function(imResponse) { console.log(imResponse.data); // 更新资料成功 }).catch(function(imError) { console.warn('updateMyProfile error:', imError); // 更新资料失败的相关信息 }); ### 步骤7:加入群组 // 匿名用户加入(无需登录,入群后仅能接收消息) let promise = tim.joinGroup({ groupID: 'avchatroom_groupID'}); promise.then(function(imResponse) { switch (imResponse.data.status) { case TIM.TYPES.JOIN_STATUS_WAIT_APPROVAL: // 等待管理员同意 break case TIM.TYPES.JOIN_STATUS_SUCCESS: // 加群成功 console.log(imResponse.data.group) // 加入的群组资料 break case TIM.TYPES.JOIN_STATUS_ALREADY_IN_GROUP: // 已经在群中 break default: break } }).catch(function(imError){ console.warn('joinGroup error:', imError) // 申请加群失败的相关信息 }); ### 步骤8:创建消息实例并发送 本文以发送文本消息为例。 // 发送文本消息,Web 端与小程序端相同 // 1. 创建消息实例,接口返回的实例可以上屏 let message = tim.createTextMessage({ to: 'avchatroom_groupID', conversationType: TIM.TYPES.CONV_GROUP, // 消息优先级,用于群聊(v2.4.2起支持)。如果某个群的消息超过了频率限制,后台会优先下发高优先级的消息,详细请参考 消息优先级与频率控制// 支持的枚举值:TIM.TYPES.MSG_PRIORITY_HIGH, TIM.TYPES.MSG_PRIORITY_NORMAL(默认), TIM.TYPES.MSG_PRIORITY_LOW, TIM.TYPES.MSG_PRIORITY_LOWESTpriority: TIM.TYPES.MSG_PRIORITY_NORMAL, payload: { text: 'Hello world!' } }) // 2. 发送消息 let promise = tim.sendMessage(message) promise.then(function(imResponse) { // 发送成功console.log(imResponse) }).catch(function(imError) { // 发送失败console.warn('sendMessage error:', imError) })
2020-04-28 - ColorUI最近出现steps组件真机调试时进度水平线位移的解决方法
首先先指出问题:我认为微信开发者工具对于calc()这个css语法存在检测性不好的情况 建议开发团队解决这个bug 其次我在说怎么解决这个问题 left: calc(0px - (100% - 80rpx) / 2); 找到图上的代码 它在colorui/main.wxss文件下的2760行左右(仔细看是左右不一定是2760行)把它改成left: calc(0rpx - (100% - 80rpx) / 2); 就好了 它的问题是微信开发者工具检测不好这个属性的px和rpx, 导致测试不准, 同学们可以多试试这个属性看看到底是哪里有问题 ,但这个问也有colorui开发团队的失误 , 问题已经反应 ,后续团队会更新这个小问题 ,最后上传一张真机调试线错乱的图片 [图片] 修改完成后线就会恢复正常 [图片] 请看仔细啊 一定要真机调试才能看出来!!!!!!!
2020-04-29 - onLaunch执行两次,请教官方!
https://developers.weixin.qq.com/s/XPgINQmv7ScN
2019-11-13 - 小程序直播视频源用websocket flv通讯可以吗?
小程序直播文档只说明用flv,rtmp格式,小程序直播视频源用websocket flv通讯可以吗?
2019-12-17 - WebSocket能够建立连接,但是就是接不到消息,后续的接口都调用不了,怎么回事?
[代码]var[代码] [代码]socketOpen = [代码][代码]false[代码][代码];[代码][代码]var[代码] [代码]frameBuffer_Data, session, SocketTask;[代码][代码]var[代码] [代码]url = [代码][代码]'wss://...'[代码][代码];[代码] [代码]Page({[代码][代码] [代码][代码]data: {[代码][代码] [代码][代码]toView: [代码][代码]'green'[代码][代码],[代码][代码] [代码][代码]windowH: [代码][代码]"1000"[代码][代码],[代码][代码] [代码][代码]user_input_text: [代码][代码]''[代码][代码],[代码][代码]//用户输入文字[代码][代码] [代码][代码]inputValue: [代码][代码]''[代码][代码],[代码][代码] [代码][代码]returnValue: [代码][代码]''[代码][代码],[代码][代码] [代码][代码]addImg: [代码][代码]false[代码][代码],[代码][代码] [代码][代码]//格式示例数据,可为空[代码][代码] [代码][代码]allContentList: [],[代码][代码] [代码][代码]num: 0,[代码][代码] [代码][代码]wo: [代码][代码]""[代码][代码],[代码][代码] [代码][代码]ta: [代码][代码]""[代码][代码],[代码][代码] [代码][代码]youImg: [代码][代码]""[代码][代码] [代码][代码]},[代码][代码] [代码][代码]//通过 WebSocket 连接发送数据,需要先 wx.connectSocket,并在 wx.onSocketOpen 回调之后才能发送。[代码][代码] [代码][代码]sendSocketMessage: [代码][代码]function[代码] [代码](msg) {[代码][代码] [代码][代码]var[代码] [代码]that = [代码][代码]this[代码][代码];[代码][代码] [代码][代码]console.log([代码][代码]'通过 WebSocket 连接发送数据'[代码][代码], JSON.stringify(msg))[代码][代码] [代码][代码]// debugger[代码][代码] [代码][代码]SocketTask.send([代码][代码] [代码][代码]{[代码][代码] [代码][代码]data: JSON.stringify(msg)[代码][代码] [代码][代码]},[代码][代码] [代码][代码]function[代码] [代码](res) {[代码][代码] [代码][代码]console.log([代码][代码]'已发送'[代码][代码], res)[代码][代码] [代码][代码]}[代码][代码] [代码][代码])[代码][代码] [代码][代码]},[代码][代码] [代码][代码]onLoad: [代码][代码]function[代码] [代码](options) {[代码][代码] [代码][代码]const that = [代码][代码]this[代码][代码];[代码][代码] [代码][代码]},[代码][代码] [代码][代码]onReady: [代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]var[代码] [代码]that = [代码][代码]this[代码][代码];[代码][代码] [代码][代码]SocketTask.onOpen(res => {[代码][代码] [代码][代码]socketOpen = [代码][代码]true[代码][代码];[代码][代码] [代码][代码]console.log([代码][代码]'监听 WebSocket 连接打开事件。'[代码][代码], res)[代码][代码] [代码][代码]//发送登陆信息[代码][代码] [代码][代码]var[代码] [代码]data = {[代码][代码] [代码][代码]// body: that.data.inputValue,[代码][代码] [代码][代码]"Name"[代码][代码]: that.data.wo,[代码][代码] [代码][代码]"content"[代码][代码]: [代码][代码]"login"[代码][代码],[代码][代码] [代码][代码]"type"[代码][代码]: 4[代码][代码] [代码][代码]}[代码][代码] [代码][代码]that.sendSocketMessage(data);[代码][代码] [代码][代码]//循环发送心跳[代码][代码] [代码][代码]setInterval([代码][代码] [代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]var[代码] [代码]ping = { [代码][代码]"type"[代码][代码]: [代码][代码]"ping"[代码] [代码]};[代码][代码] [代码][代码]that.sendSocketMessage(ping);[代码][代码] [代码][代码]}, 20000[代码][代码] [代码][代码]);[代码][代码] [代码][代码]})[代码][代码] [代码][代码]SocketTask.onClose(onClose => {[代码][代码] [代码][代码]console.log([代码][代码]'监听 WebSocket 连接关闭事件。'[代码][代码], onClose)[代码][代码] [代码][代码]socketOpen = [代码][代码]false[代码][代码];[代码][代码] [代码][代码]this[代码][代码].webSocket()[代码][代码] [代码][代码]})[代码][代码] [代码][代码]SocketTask.onError(onError => {[代码][代码] [代码][代码]console.log([代码][代码]'监听 WebSocket 错误。错误信息'[代码][代码], onError)[代码][代码] [代码][代码]socketOpen = [代码][代码]false[代码][代码] [代码][代码]})[代码][代码] [代码][代码]SocketTask.onMessage(onMessage => {[代码][代码] [代码][代码]console.log([代码][代码]"onMessage:::::"[代码] [代码]+ onMessage.data);[代码][代码] [代码][代码]if[代码] [代码](onMessage.data.indexOf([代码][代码]"上线"[代码][代码]) != -1 || onMessage.data.indexOf([代码][代码]"下线"[代码][代码]) != -1) {[代码][代码] [代码][代码]return[代码][代码];[代码][代码] [代码][代码]}[代码][代码] [代码][代码]console.log([代码][代码]'监听WebSocket接受到服务器的消息事件。服务器返回的消息'[代码][代码], JSON.parse(onMessage.data))[代码][代码] [代码][代码]var[代码] [代码]onMessage_data = JSON.parse(onMessage.data)[代码][代码] [代码][代码]if[代码] [代码](onMessage_data.toName == that.data.wo && onMessage_data.name == that.data.ta) {[代码][代码] [代码][代码]// addmsglist1(msg1.name, msg1.content)[代码][代码] [代码][代码]that.data.allContentList.push({[代码][代码] [代码][代码]"id"[代码][代码]: [代码][代码]""[代码][代码],[代码][代码] [代码][代码]"hx_id"[代码][代码]: [代码][代码]""[代码][代码],[代码][代码] [代码][代码]"wo"[代码][代码]: that.data.ta,[代码][代码] [代码][代码]"ta"[代码][代码]: that.data.wo,[代码][代码] [代码][代码]"content"[代码][代码]: onMessage_data.content,[代码][代码] [代码][代码]"voice_url"[代码][代码]: [代码][代码]""[代码][代码],[代码][代码] [代码][代码]"fileurl"[代码][代码]: [代码][代码]null[代码][代码],[代码][代码] [代码][代码]"create_date"[代码][代码]: [代码][代码]"2019-12-03"[代码][代码] [代码][代码]});[代码][代码] [代码][代码]that.setData({[代码][代码] [代码][代码]allContentList: that.data.allContentList[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码][代码] [代码][代码]})[代码][代码] [代码][代码]},[代码][代码] [代码][代码]onShow: [代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]if[代码] [代码](!socketOpen) {[代码][代码] [代码][代码]this[代码][代码].webSocket()[代码][代码] [代码][代码]}[代码][代码] [代码][代码]},[代码][代码] [代码][代码]webSocket: [代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]const that = [代码][代码]this[代码][代码];[代码][代码] [代码][代码]// 创建Socket[代码][代码] [代码][代码]SocketTask = wx.connectSocket({[代码][代码] [代码][代码]url: url,[代码][代码] [代码][代码]data: [代码][代码]'data'[代码][代码],[代码][代码] [代码][代码]header: {[代码][代码] [代码][代码]'content-type'[代码][代码]: [代码][代码]'application/json'[代码][代码] [代码][代码]},[代码][代码] [代码][代码]method: [代码][代码]'post'[代码][代码],[代码][代码] [代码][代码]success: [代码][代码]function[代码] [代码](res) {[代码][代码] [代码][代码]console.log([代码][代码]'WebSocket连接创建'[代码][代码], res)[代码][代码] [代码][代码]},[代码][代码] [代码][代码]fail: [代码][代码]function[代码] [代码](err) {[代码][代码] [代码][代码]wx.showToast({[代码][代码] [代码][代码]title: [代码][代码]'网络异常!'[代码][代码],[代码][代码] [代码][代码]})[代码][代码] [代码][代码]console.log(err)[代码][代码] [代码][代码]},[代码][代码] [代码][代码]})[代码][代码] [代码][代码]},[代码][代码] [代码][代码]onHide: [代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]SocketTask.close([代码][代码]function[代码] [代码](res) {[代码][代码] [代码][代码]console.log(res);[代码][代码] [代码][代码]})[代码][代码] [代码][代码]},[代码][代码] [代码][代码]onUnload: [代码][代码]function[代码] [代码]() {[代码][代码] [代码][代码]SocketTask.close([代码][代码]function[代码] [代码](res) {[代码][代码] [代码][代码]console.log(res);[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码][代码]})[代码][图片] 能够建立起连接,但是后续方法都没用,监听SocketTask.onOpen方法,监听不到,SocketTask.onMessage方法也监听不到
2019-10-25 - wx.hideLoading 在安卓机中无法关闭wx.showLoading
我在封装的公用请求方法中放入了这个两个api,请求调用之前调用wx.showLoading,在complete回调中调用 wx.hideLoading,但是在页面中请求成功没有正常的关闭wx.showLoading [图片] [图片][图片]
2019-12-30 - 微信更新到7.0.10之后, 大部分安卓机 wx.hideLoading 失效?
这个BUG尽快解决啊 一直卡在加载中, 都炸了, onload里面调用接口, 接口中封装了有wx.hideLoading, 全部失效, 查了官方说是onload的问题, 我换成onShow,还是会有这个BUG, 这不解决小程序全炸, 除非不用 loading .下星期还要上线啊, 无奈了 截止2020年1月3号下午16:42. 貌似还没完全修复, 暂时解决办法只能注释loading 或者写个500-1000ms的延迟关闭, onload的可以改到onReady里面 ,onReady没有这个BUG ,
2020-01-03 - textarea外使用滚动条,输入内容不随滚动条移动,模拟器可行,真机不可信
真机不行,真机不行,真机不行,重要的事说三遍 原先是以为布局搞鬼,现在使用了这个简单布局都不行 以为自己的手机不行,其它手机也不行 page,.all{width: 100%;height: 100%;padding: 0;margin: 0;} view{width: 100%;} .all{ opacity: 0.4;} .a{height: 20%;background-color: #f0f;} .b{background-color: #0ff;height:80%;overflow-y: scroll;} .b>view{height: 5rem;} .b>view:nth-child(odd){background-color: #ff0;} .b>view:nth-child(even){background-color: #0f0;} .b>view>textarea{height: 100%;width: 100%;} <view class="all"> <view class="a"> </view> <view class="b"> <view wx:for="{{[1, 2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5, 6, 7, 8, 9]}}"> <textarea value="{{'textarea外使用滚动条,输入内容不随滚动条移动,模拟器可行,真机不可行'}}"></textarea> </view> </view> </view>
2019-11-06 - websocket长时间无法连接服务器
- 当前 Bug 的表现(可附上截图) websocket长时间无法连接服务器 很简单的代码 socket = wx.connectSocket socket.onOpen sockst.send ... 偶尔会出现长时间无法连接的情况,然后不断尝试重连,大概需要1分钟左右才能成功连接。 如这时候重启微信再进入就能连接成功。 安卓、ios机型都有遇到这个问题 另外发现,这种情况在wifi下多人同时连接同一个wss服务器时会碰到比较多。而切换到4G网络下很少碰到。 错误信息:WebSocket connection to 'wss://................' failed: WebSocket is closed before the connection is established. (因为有个超时检查,几秒钟后连接不上就socket.close()) 发生问题时,通过抓包未发现有网络异常。 - 预期表现 能正常快速建立连接 - 复现路径 首次打开小程序很少碰到这种情况,但是回到微信聊天从分享卡片再次进入小程序后碰到概率很大。 - 提供一个最简复现 Demo 在提问前搜索了一下相关帖子,发现"7.0下websocket连接问题"反馈比较多,不知道我这种情况是否也是微信7.0的问题导致?
2019-01-23 - 微信小程序 如何把使用MD5加密emjoy字符串,比如'Stefan👣'
最近在获取到微信的昵称后,需要使用MD5加密传给后台,但是发现小程序直接使用可以加密中英文的md5.js来加密还有emjoy字符串的昵称,与服务器的加密不一致。有想法把emjoy字符串转成base64,但是发现没有emjoy字符串base64转换的js,所以想咨询一下,emjoy字符串在小程序中,改如果进行MD5加密或者改怎么转换成base64
2018-09-12 - 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 - 刚更新了开发者工具,然后每次跳转页面控制台都会打印出一大堆东西,怎么去掉呢
[图片] 如题如图,麻烦有官方来给个解答么
2019-07-29 - 微信安卓7.0.4以及7.0.5版本,websocket建立连接成功后报错
问题描述: 使用官方websocketAPI后,在ios手机上运行没有问题,但是在微信7.0.4和7.0.5版本部分安卓真机上建立websocket连接后可以进入success回调,但是会自动断开连接,报socket error: {errMsg: "exception onOpen fail code:20, msg:Invalid HTTP status."}错误或等待一段时间报socket error: {errMsg: "connect response time out"}错误。 部分机型: 红米5 plus(android8.1.0) 华为荣耀note10(android9) 华为mate 10(android8.1.0) vivo Z5x(andorid9) 现象描述: 红米5 plus、华为荣耀note10,调用wx.connectSocket建立连接,可进入success回调,然后监听到onSocketError,报socket error: {errMsg: "exception onOpen fail code:20, msg:Invalid HTTP status."}错误。 vivo Z5x、华为mate 10,调用wx.connectSocket建立连接,可进入success回调,过大约60秒后报socket error: {errMsg: "connect response time out"}错误。
2019-07-15 - 小程序canvas生成海报图(易错点分析)
1,问:创建canvas 绘制图像,保存的图片不清晰? 答:这个问题大概就是canvas宽高太小。 2,问:绘制图像,图片绘制不上? 答:绘制图片的时候图片路径必须为本地路径,如果使用网络路径是不行的,我们只需要将网络路径下载到本地就行wx.downloadFile()。 3,问:绘制好的图像,图片保存不了? 答:我们绘制好的图像,要保存成图片,其实需要2步。 第1步:利用wx.canvasToTempFilePath()将canvas画布图像,生成图片。我们需要将wx.canvasToTempFilePath(),放到 ctx.draw(false, function() { wx.canvasToTempFilePath()生成的图片路径进行 }) 里面确保canvas已经将图像全部绘制完成。 第2步:将wx.canvasToTempFilePath()生成的图片路径进行 wx.saveImageToPhotosAlbum()保存到相册就ok。 以上只是个人遇到的问题,如有步骤不明确请在评论区提问。 谢谢大家的支持。
2019-07-03 - 使用Puppeteer搭建统一海报渲染服务
背景介绍 有赞微商城包括了 PC 端、H5 端和小程序端,每个端都有绘制分享海报的需求。最早的时候我们是在每个端通过canvas API来绘制的,通过canvas绘制有很多痛点,与本文要讲的海报渲染服务做了一个对比: [图片] 正是因为这些痛点问题,有同事就提出基于Puppeteer实现一个公共的海报渲染服务,使用方只需传入海报图片的html,海报渲染服务绘制一张对应的图片作为返回结果,解决了canvas绘制的各种痛点问题。 一、Puppeteer 是什么 Puppeteer是谷歌官方团队开发的一个 Node 库,它提供了一些高级 API 来通过DevTools协议控制Headless Chrome或Chromium。通俗的说就是提供了一些 API 用来控制浏览器的行为,比如打开网页、模拟输入、点击按钮、屏幕截图等操作,通过这些 API 可以完成很多有趣的事情,比如本文要讲的海报渲染服务,它用到的就是屏幕截图的功能。 二、Puppeteer 能做什么 Puppeteer几乎能实现你能在浏览器上做的任何事情,比如: 生成页面的屏幕截图或pdf 自动化提交表单、模拟键盘输入、自动化单元测试等 网站性能分析:可以抓取并跟踪网站的执行时间轴,帮助分析效率问题 抓取网页内容,也就是我们常说的爬虫 三、海报渲染服务 3.1方案设计 首先我们来看一下海报渲染服务的流程图: [图片] 其实整个流程还是比较简单的,当有一个绘制请求时,首先看之前是否已经绘制过相同的海报了,如果绘制过,就直接从Redis里取出海报图片的 CDN 地址。如果海报未曾绘制过,则先调用Headless Chrome来绘制海报,绘制完后上传到 CDN,最后 CDN 上传完后返回 CDN 地址。整个流程的大致代码实现如下: [代码]const crypto = require('crypto'); const PuppeteerProvider = require('../../lib/PuppeteerProvider'); const oneDay = 24 * 60 * 60; class SnapshotController { /** - 截图接口 * - @param {Object} ctx 上下文 */ async postSnapshotJson(ctx) { const result = await this.handleSnapshot(); ctx.json(0, 'ok', result); } async handleSnapshot() { const { ctx } = this; const { html } = ctx.request.body; // 根据 html 做 sha256 的哈希作为 Redis Key const htmlRedisKey = crypto.createHash('sha256').update(html).digest('hex'); try { // 首先看海报是否有绘制过的 let result = await this.findImageFromCache(htmlRedisKey); // 命中缓存失败 if (!result) { result = await this.generateSnapshot(htmlRedisKey); } return result; } catch (error) { ctx.status = 500; return ctx.throw(500, error.message); } } /** - 判断kv中是否有缓存 * - @param {String} htmlRedisKey kv存储的key */ async findImageFromCache(htmlRedisKey) { } /** - 生成截图 * - @param {String} htmlRedisKey kv存储的key */ async generateSnapshot(htmlRedisKey) { const { ctx } = this; const { html, width = 375, height = 667, quality = 80, ratio = 2, type: imageType = 'jpeg', } = ctx.request.body; this.validator .required(html, '缺少必要参数 html') .required(operatorId, '缺少必要参数 operatorId'); let imgBuffer; try { imgBuffer = await PuppeteerProvider.snapshot({ html, width, height, quality, ratio, imageType }); } catch (err) { // logger } let imgUrl; try { imgUrl = await this.uploadImage(imgBuffer, operatorId); // 将海报图片存在 Redis 里 await ctx.kvdsClient.setex(htmlRedisKey, oneDay, imgUrl); } catch (err) { } return { img: imgUrl || '', type: IMAGE_TYPE_MAP.CDN, }; } /** - 上传图片到七牛 * - @param {Buffer} imgBuffer 图片buffer */ async uploadImage(imgBuffer) { // upload image to cdn and return cdn url } } module.exports = SnapshotController; [代码] 3.2遇到的问题 2.3.1 Chromium启动和执行流程 最开始一个版本我们是直接Puppeteer.launch()返回一个浏览器实例,每次绘制会用单独的一个浏览器实例,这个在使用过程中发现绘制海报会很慢,后面优化时找到了这篇文章:Puppeteer性能优化与执行速度提升,这篇文章提到了两个优化点:1. 优化Chromium启动项;2. 优化Chromium执行流程。 先说优化Chromium启动项,这个就是为了我们启动一个最小化可用的浏览器实例,其他不需要的功能都禁用掉,这样会大大提升启动速度。 [代码]const browser = await puppeteer.launch({ args: [ '–disable-gpu', '–disable-dev-shm-usage', '–disable-setuid-sandbox', '–no-first-run', '–no-sandbox', '–no-zygote', '–single-process' ] }); [代码] 再来说说浏览器的执行流程,最开始我们是每次绘制都会用单独一个浏览器,也就是一对一,这个在压测的时候发现CPU和内存飙升,最后我们改用了复用浏览器标签的方式,每次绘制新建一个标签来绘制。 [代码]const page = await browser.newPage(); page.setContent(html, { waitUntil: 'networkidle0' }); const imageBuffer = await page.screeshot(options); [代码] 3.3.2 networkidle0 最开始我们的海报服务绘制海报时有时候会偶尔出现图片展示不出来的情况,我们排查后发现是因为我们setContent时,使用的是默认的load事件来判断设置内容成功,而我们期望的是所有网络请求成功后才算设置内容成功。 [代码]page.setContent(html); [代码] Puppeteer在setContent和goto等方法里提供了一个waitUntil的参数,它就是用来配置这个判断成功的标准,它提供了四个可选值: load:默认值,load事件触发就算成功 domcontentloaded:domcontentloaded事件触发就算成功 networkidle0:在500ms内没有网络连接时就算成功 networkidle2:在500ms内有不超过2个网络连接时就算成功 我们这里需要用到的就是networkidle0: [代码]page.setContent(html, { waitUntil: 'networkidle0' }); [代码] 当改成networkidle0后,使用方给我们反馈说整个绘制服务变慢了很多,随随便便都2s以上。变慢主要是因为加上networkidle0后,至少需要等待500ms以上,加上绘制的一些其他开销,基本上就需要2s了。所以我们期望这个500ms是可配置的,因为500ms实在太长了,我们的分享海报一般只有几张图片,不需要这么久。但是Puppeteer没有提供相关的参数,还好在issue中早已经有人提出了这个问题:Control networkidle wait time [代码]function waitForNetworkIdle(page, timeout, maxInflightRequests = 0) { page.on('request', onRequestStarted); page.on('requestfinished', onRequestFinished); page.on('requestfailed', onRequestFinished); let inflight = 0; let fulfill; let promise = new Promise(x => fulfill = x); let timeoutId = setTimeout(onTimeoutDone, timeout); return promise; function onTimeoutDone() { page.removeListener('request', onRequestStarted); page.removeListener('requestfinished', onRequestFinished); page.removeListener('requestfailed', onRequestFinished); fulfill(); } function onRequestStarted() { ++inflight; if (inflight > maxInflightRequests) clearTimeout(timeoutId); } function onRequestFinished() { if (inflight === 0) return; --inflight; if (inflight === maxInflightRequests) timeoutId = setTimeout(onTimeoutDone, timeout); } } // Example await Promise.all([ page.goto('https://google.com'), waitForNetworkIdle(page, 500, 0), // equivalent to 'networkidle0' ]); [代码] 利用这个函数我们就可以传入自己想要的超时时间了。 3.2.3 Chromium定时刷新机制 为什么需要定时刷新Chromium呢?总不可能一直用同一个Chromium实例吧,万一变卡或者crash了,就会影响海报的绘制。所以我们需要定时的去刷新当前的浏览器实例。 [代码]class PuppeteerProvider { constructor() { this.browserList = []; } /** - 初始化`puppeteer`实例 */ initBrowserInstance() { Array.from({ length: browserConcurrency }, () => { this.checkBrowserInstance(); }); // 每隔30分钟刷新一下浏览器 this.refreshTimer = setTimeout(() => this.refreshOneBrowser(), thrityMinutes); } /** - 检查是否还需要浏览器实例 */ async checkBrowserInstance() { if (this.needBrowserInstance) { this.browserList.push(this.launchBrowser()); } } /** - 定时刷新浏览器 */ refreshOneBrowser() { clearTimeout(this.refreshTimer); const browserInstance = this.browserList.shift(); this.replaceBrowserInstance(browserInstance); this.checkBrowserInstance(); // 每隔30分钟刷新一下浏览器 this.refreshTimer = setTimeout(() => this.refreshOneBrowser(), thrityMinutes); } /** - 替换单个浏览器实例 * - @param {String} browserInstance 浏览器promise - @param {String} retries 重试次数,超过这个次数直接关闭浏览器 */ async replaceBrowserInstance(browserInstance, retries = 2) { const browser = await browserInstance; const openPages = await browser.pages(); // 因为浏览器会打开一个空白页,如果当前浏览器还有任务在执行,一分钟后再关闭 if (openPages && openPages.length > 1 && retries > 0) { const nextRetries = retries - 1; setTimeout(() => this.replaceBrowserInstance(browserInstance, nextRetries), oneMinute); return; } browser.close(); } launchBrowser(opts = {}, retries = 1) { return PuppeteerHelper.launchBrowser(opts).then(chrome => { return chrome; }).catch(error => { if (retries > 0) { const nextRetries = retries - 1; return this.launchBrowser(opts, nextRetries); } throw error; }); } } [代码] 这里还有一个点,我们给replaceBrowserInstance这个方法加了个重试次数的限制,当超出这个限制后不管有没有任务在进行都会关闭浏览器。这个是防止在某些特殊情况不能关闭掉浏览器,导致内存无法释放的情况。 四、展望 目前海报渲染服务的问题就是qps比较低,因为Chromium消耗最多的资源是CPU,当并发数变高时,CPU也随之变高,就会导致后面的绘制变慢。在4核8G的情况,大概是20qps左右。后面的主要精力就是如何去提升单机的qps,应该还有比较大的空间。还有就是看看能不能增加定时任务,在凌晨机器比较闲的时候提前绘制好一些常用的海报,这样当需要海报时就是直接从redis里取出来了,充分利用了机器的性能,也可以减少海报服务白天的压力。 五、相关链接: Puppeteer 性能优化与执行速度提升:https://blog.it2048.cn/article-puppeteer-speed-up/ Control networkidle wait time:https://github.com/GoogleChrome/puppeteer/issues/1353
2019-06-13 - 如何实现快速生成朋友圈海报分享图
由于我们无法将小程序直接分享到朋友圈,但分享到朋友圈的需求又很多,业界目前的做法是利用小程序的 Canvas 功能生成一张带有小程序码的图片,然后引导用户下载图片到本地后再分享到朋友圈。相信大家在绘制分享图中应该踩到 Canvas 的各种(坑)彩dan了吧~ 这里首先推荐一个开源的组件:painter(通过该组件目前我们已经成功在支付宝小程序上也应用上了分享图功能) 咱们不多说,直接上手就是干。 [图片] 首先我们新增一个自定义组件,在该组件的json中引入painter [代码]{ "component": true, "usingComponents": { "painter": "/painter/painter" } } [代码] 然后组件的WXML (代码片段在最后) [代码]// 将该组件定位在屏幕之外,用户查看不到。 <painter style="position: absolute; top: -9999rpx;" palette="{{imgDraw}}" bind:imgOK="onImgOK" /> [代码] 重点来了 JS (代码片段在最后) [代码]Component({ properties: { // 是否开始绘图 isCanDraw: { type: Boolean, value: false, observer(newVal) { newVal && this.handleStartDrawImg() } }, // 用户头像昵称信息 userInfo: { type: Object, value: { avatarUrl: '', nickName: '' } } }, data: { imgDraw: {}, // 绘制图片的大对象 sharePath: '' // 生成的分享图 }, methods: { handleStartDrawImg() { wx.showLoading({ title: '生成中' }) this.setData({ imgDraw: { width: '750rpx', height: '1334rpx', background: 'https://qiniu-image.qtshe.com/20190506share-bg.png', views: [ { type: 'image', url: 'https://qiniu-image.qtshe.com/1560248372315_467.jpg', css: { top: '32rpx', left: '30rpx', right: '32rpx', width: '688rpx', height: '420rpx', borderRadius: '16rpx' }, }, { type: 'image', url: this.data.userInfo.avatarUrl || 'https://qiniu-image.qtshe.com/default-avatar20170707.png', css: { top: '404rpx', left: '328rpx', width: '96rpx', height: '96rpx', borderWidth: '6rpx', borderColor: '#FFF', borderRadius: '96rpx' } }, { type: 'text', text: this.data.userInfo.nickName || '青团子', css: { top: '532rpx', fontSize: '28rpx', left: '375rpx', align: 'center', color: '#3c3c3c' } }, { type: 'text', text: `邀请您参与助力活动`, css: { top: '576rpx', left: '375rpx', align: 'center', fontSize: '28rpx', color: '#3c3c3c' } }, { type: 'text', text: `宇宙最萌蓝牙耳机测评员`, css: { top: '644rpx', left: '375rpx', maxLines: 1, align: 'center', fontWeight: 'bold', fontSize: '44rpx', color: '#3c3c3c' } }, { type: 'image', url: 'https://qiniu-image.qtshe.com/20190605index.jpg', css: { top: '834rpx', left: '470rpx', width: '200rpx', height: '200rpx' } } ] } }) }, onImgErr(e) { wx.hideLoading() wx.showToast({ title: '生成分享图失败,请刷新页面重试' }) //通知外部绘制完成,重置isCanDraw为false this.triggerEvent('initData') }, onImgOK(e) { wx.hideLoading() // 展示分享图 wx.showShareImageMenu({ path: e.detail.path, fail: err => { console.log(err) } }) //通知外部绘制完成,重置isCanDraw为false this.triggerEvent('initData') } } }) [代码] 那么我们该如何引用呢? 首先json里引用我们封装好的组件share-box [代码]{ "usingComponents": { "share-box": "/components/shareBox/index" } } [代码] 以下示例为获取用户头像昵称后再生成图。 [代码]<button class="intro" bindtap="getUserInfo">点我生成分享图</button> <share-box isCanDraw="{{isCanDraw}}" userInfo="{{userInfo}}" bind:initData="handleClose" /> [代码] 调用的地方: [代码]const app = getApp() Page({ data: { isCanDraw: false }, // 组件内部关掉或者绘制完成需重置状态 handleClose() { this.setData({ isCanDraw: !this.data.isCanDraw }) }, getUserInfo(e) { wx.getUserProfile({ desc: "获取您的头像昵称信息", success: res => { const { userInfo = {} } = res this.setData({ userInfo, isCanDraw: true // 开始绘制海报图 }) }, fail: err => { console.log(err) } }) } }) [代码] 最后绘制分享图的自定义组件就完成啦~效果图如下: [图片] tips: 文字居中实现可以看下代码片段 文字换行实现(maxLines)只需要设置宽度,maxLines如果设置为1,那么超出一行将会展示为省略号 代码片段:https://developers.weixin.qq.com/s/J38pKsmK7Qw5 附上painter可视化编辑代码工具:点我直达,因为涉及网络图片,代码片段设置不了downloadFile合法域名,建议真机开启调试模式,开发者工具 详情里开启不校验合法域名进行代码片段的运行查看。 最后看下面大家评论问的较多的问题:downLoadFile合法域名在小程序后台 开发>开发设置里配置,域名为你图片的域名前缀 比如我文章里的图https://qiniu-image.qtshe.com/20190605index.jpg。配置域名时填写https://qiniu-image.qtshe.com即可。如果你图片cdn地址为https://aaa.com/xxx.png, 那你就配置https://aaa.com即可。
2022-01-20 - 亲测有效隐藏scroll-view滚动条方法
在有scroll-view滚动条页面的wxss里,例如在首页index.wxss,添加 [代码]::-webkit-scrollbar { display: none; width: 0; height: 0; color: transparent; } [代码] 不用选择器,以及不能在app.wxss直接添加。
2019-06-11 - Vue项目当中调用微信sdk解决方案
1:安装微信sdk,cnpm install weixin-js-sdk -S; 2:安装完成之后再main.js文件引入,注入到vue原型 import wx-sdk from “weixin-js-sdk”; 3:在第二个或者第三个页面去调用后台提供的接口初始化微信的sdk(最好在第二个页面去初始化,不然会遇到一个生命周期的问题) [图片] 参数说明:debug:true||false,查看初始化结果,成功与否,appID:公众号的APPID。timestamp:生成签名的时间戳。 nonceStr:生成签名的随机字符串。signature:微信生成的签名。jsApiList:需要在项目当中使用的那些方法,比如说支付chooseWXPay,直接把方法写进jsApiList里面既可。 4:我这里用微信支付演示一下 [图片] 然后调起支付的参数就和小程序是一样的道理,由后台给你传过来即可。 微信小程序开发交流群纯交流群,没有任何广告,学习微信小程序的欢迎加入,里面有性感大牛在线解决问题 [图片]
2019-05-31 - 系统信息中的窗口高度
今天发现,在app.js 中执行 wx.getSystemInfoSync() 获取系统信息的 windowHeight是减去 tabbar 的。 在没有tabbar的 页面执行 后获取的高度是算上tabbar高度的。 这样原来想着在 globalData里放上 一次系统信息就可以调用的想法在这个 windowHeight 上是不行的。
2019-05-31 - 计算文本段落的高度和行数
段落的高度和行数,在canvas绘制海报图的场景下需要用到,其他使用场景暂时还没遇到。 使用微信接口获取段落高度: wx.createSelectorQuery().selectAll('.paragraph').fields({ size: true, }, function (res) { //res里面有高度值 }).exec() 获取行数: 自己模拟写一个只有一行的段落,比如<view class="demo">模拟一行</view>,样式跟原段落设置成一样。 使用上面的高度接口,获取段落只有一行时的高度,然后 原段落高度/一行段落高度 = 原段落行数
2019-06-03 - 一个菜单按钮
去找小程序的菜单按钮,没有找到,于是自己摆弄了一个出来,虽然是个很简单的东西,考虑到可能还有其他人觉得写一个麻烦,现在把代码发一下,大神勿喷。 先看一下效果: [图片] 代码: cc-mainbutton.js [代码] Component({ lifetimes: { attached: function attached() { // 在组件实例进入页面节点树时执行 this.animation = wx.createAnimation(); }, detached: function detached() { // 在组件实例被从页面节点树移除时执行 } }, data: { dial_btn_options_show: false }, methods: { // 菜单按钮的动画 rotate: function rotate() { if (this.data.dial_btn_options_show == false) { this.animation.rotate(-135).step(); this.setData({ dial_btn_options_show: true animation: this.animation.export() }); } else { this.animation.rotate(0).step(); this.setData({ dial_btn_options_show: false animation: this.animation.export() }); } }, //点击子按钮 click_option: function click_option(e) { switch (e.currentTarget.dataset.option) { case '1': break; case '2': break; case '3': break; default: break; } } } }); [代码] cc-mainbutton.wxml [代码]<view class="main_btn_ctn" style="width: 60px;height: 60px;"> <image animation="{{animation}}" bindtap="rotate" class="dial-btn {{dial_btn_options_show?'dial-btn-active':''}}" src="../static/images/main-btn.png" /> <view bindtap="click_option" data-option="1" class="dial-btn--option flex-def flex-zCenter flex-cCenter flex-zTopBottom"> <image style="height: 25px;width: 25px" class="" src="../static/images/add_shuoshuo.png" mode="widthFix" /> </view> <view bindtap="click_option" data-option="2" class="dial-btn--option flex-def flex-zCenter flex-cCenter flex-zTopBottom"> <image style="height: 25px;width: 25px" class="" src="../static/images/reflesh.png" mode="widthFix" /> </view> <view bindtap="click_option" data-option="3" class="dial-btn--option flex-def flex-zCenter flex-cCenter flex-zTopBottom"> <image style="height: 25px;width: 25px" class="" src="../static/images/go-top.png" mode="widthFix" /> </view> </view> [代码] cc-mainbutton.wxss。 [代码]/* index/main-button/cc-mainbutton.wxss */ .flex-def { display: flex; } /* 主轴居中 */ .flex-zCenter { justify-content: center; } /* 侧轴居中 */ .flex-cCenter { align-items: center; } /* 主轴从上到下 */ .flex-zTopBottom { flex-direction: column; } .dial-btn { border: none; z-index: 7; position: absolute; height: 60px; width: 60px; left: 50%; top: 50%; margin: -30px 0 0 -30px; } /*子按钮初始位置隐藏在主按钮后面,透明度0*/ .dial-btn--option { background: yellowgreen; position: absolute; height: 46px; width: 46px; border-radius: 100%; left: 50%; top: 50%; margin: -23px 0 0 -23px; transform: translate(0, 0); /* 过渡效果 */ transition: opacity 0.25s ease-in-out, transform 0.25s ease 0s; } .dial-btn--option:nth-of-type(1) { z-index: 2; opacity: 0; transition-delay: 0.2s; } .dial-btn--option:nth-of-type(2) { z-index: 3; opacity: 0; transition-delay: 0.3s; } .dial-btn--option:nth-of-type(3) { z-index: 4; opacity: 0; transition-delay: 0.4s; } /* 通过nth-of-type定义每个子按钮的不同定位,设置透明度1 */ .dial-btn-active ~ .dial-btn--option:nth-of-type(1) { opacity: 1; transform: translate(-65px, 5px); } .dial-btn-active ~ .dial-btn--option:nth-of-type(2) { opacity: 1; transform: translate(-40px, -40px); } .dial-btn-active ~ .dial-btn--option:nth-of-type(3) { opacity: 1; transform: translate(5px, -65px); } [代码] 预览网址:https://developers.weixin.qq.com/s/if7B8SmT7E8q
2019-06-04 - 页面跳转封装
export default (options, type = 1) => { return new Promise((reslove, reject) => { routes[type](Object.assign(getPath(options), { success: reslove, fail: reject, })); }); }; function getPath(options) { switch (Reflect.toString.call(options)) { case “[object Object]”: return { url: [代码]${options.url}?data=${encodeURIComponent(JSON.stringify(options.data))}[代码], }; case “[object Number]”: return { delta: options, }; case “[object String]”: return { url: options, }; } } const routes = { 1: wx.navigateTo, 2: wx.switchTab, 3: wx.navigateBack, 4: wx.reLaunch, 5: wx.redirectTo, };
2019-06-04 - 小程序富文本能力的深入研究与应用
前言 在开发小程序的过程中,很多时候会需要使用富文本内容,然而现有的方案都有着或多或少的缺陷,如何更好的显示富文本将是一个值得继续探索的问题。 [图片] 现有方案 WxParse [代码]WxParse[代码] 作为一个应用最为应用最广泛的富文本插件,在很多时候是大家的首选,但其也明显的存在许多问题。 格式不正确时标签会被原样显示 很多人可能都见到过这种情况,当标签里的内容出现格式上的错误(如冒号不匹配等),在[代码]WxParse[代码]中都会被认为是文本内容而原样输出,例如:[代码]<span style="font-family:"宋体"">Hello World!</span> [代码] 这是由于[代码]WxParse[代码]的解析脚本中,是通过正则匹配的方式进行解析,一旦格式不正确,就将导致无法匹配而被直接认为是文本[代码]//WxParse的匹配模式 var startTag = /^<([-A-Za-z0-9_]+)((?:\s+[a-zA-Z_:][-a-zA-Z0-9_:.]*(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)>/, endTag = /^<\/([-A-Za-z0-9_]+)[^>]*>/, attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:(?:"((?:\\.|[^"])*)")|(?:'((?:\\.|[^'])*)')|([^>\s]+)))?/g; [代码] 然而,[代码]html[代码] 对于格式的要求并不严格,一些诸如冒号不匹配之类的问题是可以被浏览器接受的,因此需要在解析脚本的层面上提高容错性。 超过限定层数时无法显示 这也是一个让许多人十分苦恼的问题,[代码]WxParse[代码] 通过 [代码]template[代码] 迭代的方式进行显示,当节点的层数大于设定的 [代码]template[代码] 数时就会无法显示,自行增加过多的层数又会大大增加空间大小,因此对于 [代码]wxml[代码] 的渲染方式也需要改进。 对于表格、列表等复杂内容支持性差 [代码]WxParse[代码] 对于 [代码]table[代码]、[代码]ol[代码]、[代码]ul[代码] 等支持性较差,类似于表格单元格合并,有序列表,多层列表等都无法渲染 rich-text [代码]rich-text[代码] 组件作为官方的富文本组件,也是很多人选择的方案,但也存在着一些不足之处 一些常用标签不支持 [代码]rich-text[代码] 支持的标签较少,一些常用的标签(比如 [代码]section[代码])等都不支持,导致其很难直接用于显示富文本内容 ps:最新的 2.7.1 基础库已经增加支持了许多语义化标签,但还是要考虑低版本兼容问题 不能实现图片和链接的点击 [代码]rich-text[代码] 组件中会屏蔽所有结点事件,这导致无法实现图片点击预览,链接点击效果等操作,较影响体验 不支持音视频 音频和视频作为富文本的重要内容,在 [代码]rich-text[代码] 中却不被支持,这也严重影响了使用体验 共同问题 不支持解析 [代码]style[代码] 标签 现有的方案中都不支持对 [代码]style[代码] 标签中的内容进行解析和匹配,这将导致一些标签样式的不正确 [图片] 方案构建 因此要解决上述问题,就得构建一个新的方案来实现 渲染方式 对于该节点下没有图片、视频、链接等的,直接使用 [代码]rich-text[代码] 显示(可以减少标签数,提高渲染效果),否则则继续进行深入迭代,例如: [图片] 对于迭代的方式,有以下两种方案: 方案一 像 [代码]WxParse[代码] 那样通过 [代码]template[代码] 进行迭代,对于小于 20 层的内容,通过 [代码]template[代码] 迭代的方式进行显示,超过 20 层时,用 [代码]rich-text[代码] 组件兜底,避免无法显示,这也是一开始采用的方案[代码]<!--超过20层直接使用rich-text--> <template name='rich-text-floor20'> <block wx:for='{{nodes}}' wx:key> <rich-text nodes="{{item}}" /> </block> </template> [代码] 方案二 添加一个辅助组件 [代码]trees[代码],通过组件递归的方式显示,该方式实现了没有层数的限制,且避免了多个重复性的 [代码]template[代码] 占用空间,也是最终采取的方案[代码]<!--继续递归--> <trees wx:else id="node" class="{{item.name}}" style="{{item.attrs.style}}" nodes="{{item.children}}" controls="{{controls}}" /> [代码] 解析脚本 从 [代码]htmlparser2[代码] 包进行改写,其通过状态机的方式取代了正则匹配,有效的解决了容错性问题,且大大提升了解析效率 [代码]//不同状态各通过一个函数进行判断和状态跳转 for (; this._index < this._buffer.length; this._index++) this[this._state](this._buffer[this._index]); [代码] 兼容 [代码]rich-text[代码] 为了解析结果能同时在 [代码]rich-text[代码] 组件上显示,需要对一些 [代码]rich-text[代码]不支持的组件进行转换[代码]//以u标签为例 case 'u': name = 'span'; attrs.style = 'text-decoration:underline;' + attrs.style; break; [代码] 适配渲染需要 在渲染过程中,需要对节点下含有图片、视频、链接等不能由 [代码]rich-text[代码]直接显示的节点继续迭代,否则直接使用 [代码]rich-text[代码] 组件显示;因此需要在解析过程中进行标记,遇到 [代码]img[代码]、[代码]video[代码]、[代码]a[代码] 等标签时,对其所有上级节点设置一个 [代码]continue[代码] 属性用于区分[代码]case 'a': attrs.style = 'color:#366092;display:inline;word-break:break-all;overflow:auto;' + attrs.style; element.continue = true; //冒泡:对上级节点设置continue属性 this._bubbling(); break; [代码] 处理style标签 解析方式 方案一 正则匹配[代码]var classes = style.match(/[^\{\}]+?\{([^\{\}]*?({[\s\S]*?})*)*?\}/g); [代码] 缺陷: 当 [代码]style[代码] 字符串较长时,可能出现栈溢出的问题 对于一些复杂的情况,可能出现匹配失败的问题 方案二 状态机的方式,类似于 [代码]html[代码] 字符串的处理方式,对于 [代码]css[代码] 的规则进行了调整和适配,也是目前采取的方案 匹配方式 方案一 将 [代码]style[代码] 标签解析为一个形如 [代码]{key:content}[代码] 的结构体,对于每个标签,通过访问结构体的相应属性即可知晓是否匹配成功[代码]if (this._style[name]) attrs.style += (';' + this._style[name]); if (this._style['.' + attrs.class]) attrs.style += (';' + this._style['.' + attrs.class]); if (this._style['#' + attrs.id]) attrs.style += (';' + this._style['#' + attrs.id]); [代码] 优点:匹配效率高,适合前端对于时间和空间的要求 缺点:对于多层选择器等复杂情况无法处理 因此在前端组件包中采取的是这种方式进行匹配 方案二 将 [代码]style[代码] 标签解析为一个数组,每个元素是形如 [代码]{key,list,content,index}[代码] 的结构体,主要用于多层选择器的匹配,内置了一个数组 [代码]list[代码] 存储各个层级的选择器,[代码]index[代码] 用于记录当前的层数,匹配成功时,[代码]index++[代码],匹配成功的标签出栈时,[代码]index--[代码];通过这样的方式可以匹配多层选择器等更加复杂的情况,但匹配过程比起方案一要复杂的多。 [图片] 遇到的问题 [代码]rich-text[代码] 组件整体的显示问题 在显示过程中,需要把 [代码]rich-text[代码] 作为整体的一部分,在一些情况下会出现问题,例如: [代码]Hello <rich-text nodes="<div style='display:inline-block'>World!</div>"/> [代码] 在这种情况下,虽然对 [代码]rich-text[代码] 中的顶层 [代码]div[代码] 设置了 [代码]display:inline-block[代码],但没有对 [代码]rich-text[代码] 本身进行设置的情况下,无法实现行内元素的效果,类似的还有 [代码]float[代码]、[代码]width[代码](设置为百分比时)等情况 解决方案 方案一 用一个 [代码]view[代码] 包裹在 [代码]rich-text[代码] 外面,替代最外层的标签[代码]<view style="{{item.attrs.style}}"> <rich-text nodes="{{item.children}}" /> </view> [代码] 缺陷:当该标签为 [代码]table[代码]、[代码]ol[代码] 等功能性标签时,会导致错误 方案二 对 [代码]rich-text[代码] 组件使用最外层标签的样式[代码]<rich-text nodes="{{item}}" style="{{item.attrs.style}}" /> [代码] 缺陷:当该标签的 [代码]style[代码] 中含有 [代码]margin[代码]、[代码]padding[代码] 等内容时会被缩进两次 方案三 通过 [代码]wxs[代码] 脚本将顶层标签的 [代码]display[代码]、[代码]float[代码]、[代码]width[代码] 等样式提取出来放在 [代码]rich-text[代码] 组件的 [代码]style[代码] 中,最终解决了这个问题[代码]var res = ""; var reg = getRegExp("float\s*:\s*[^;]*", "i"); if (reg.test(style)) res += reg.exec(style)[0]; reg = getRegExp("display\s*:\s*([^;]*)", "i"); if (reg.test(style)) { var info = reg.exec(style); res += (';' + info[0]); display = info[1]; } else res += (';display:' + display); reg = getRegExp("[^;\s]*width\s*:\s*[^;]*", "ig"); var width = reg.exec(style); while (width) { res += (';' + width[0]); width = reg.exec(style); } return res; [代码] 图片显示的问题 在 [代码]html[代码] 中,若 [代码]img[代码] 标签没有设置宽高,则会按照原大小显示;设置了宽或高,则按比例进行缩放;同时设置了宽高,则按设置的宽高进行显示;在小程序中,若通过 [代码]image[代码] 组件模拟,需要通过 [代码]bindload[代码] 来获取图片宽高,再进行 [代码]setData[代码],当图片数量较大时,会大大降低性能;另外,许多图片的宽度会超出屏幕宽度,需要加以限制 解决方案 用 [代码]rich-text[代码] 中的 [代码]img[代码] 替代 [代码]image[代码] 组件,实现更加贴近 [代码]html[代码] 的方式 ;对 [代码]img[代码] 组件设置默认的效果 [代码]max-width:100%;[代码] 视频显示的问题 当一个页面出现过多的视频时,同时进行加载可能导致页面卡死 解决方案 在解析过程中进行计数,若视频数量超过3个,则用一个 [代码]wxss[代码] 绘制的图片替代 [代码]video[代码] 组件,当受到点击时,再切换到 [代码]video[代码] 组件并设置 [代码]autoplay[代码] 以模拟正常效果,实现了一个类似懒加载的功能 [代码]<!--视频--> <view wx:if="{{item.attrs.id>'media3'&&!controls[item.attrs.id].play}}" class="video" data-id="{{item.attrs.id}}" bindtap="_loadVideo"> <view class="triangle_border_right"></view> </view> <video wx:else src='{{controls[item.attrs.id]?item.attrs.source[controls[item.attrs.id].index]:item.attrs.src}}' id="{{item.attrs.id}}" autoplay="{{item.attrs.autoplay||controls[item.attrs.id].play}}" /> [代码] 文本复制的问题 小程序中只有 [代码]text[代码] 组件可以通过设置 [代码]selectable[代码] 属性来实现长按复制,在富文本组件中实现这一功能就存在困难 解决方案 在顶层标签上加上 [代码]user-select:text;-webkit-user-select[代码] [图片] 实现更加丰富的功能 在此基础上,还可以实现更多有用的功能 自动设置页面标题 在浏览器中,会将 [代码]title[代码] 标签中的内容设置到页面标题上,在小程序中也同样可以实现这样的功能[代码]if (res.title) { wx.setNavigationBarTitle({ title: res.title }) } [代码] 多资源加载 由于平台差异,一些媒体文件在不同平台可能兼容性有差异,在浏览器中,可以通过 [代码]source[代码] 标签设置多个源,当一个源加载失败时,用下一个源进行加载和播放,在本插件中同样可以实现这样的功能[代码]errorEvent(e) { //尝试加载其他源 if (!this.data.controls[e.currentTarget.dataset.id] && e.currentTarget.dataset.source.length > 1) { this.data.controls[e.currentTarget.dataset.id] = { play: false, index: 1 } } else if (this.data.controls[e.currentTarget.dataset.id] && e.currentTarget.dataset.source.length > (this.data.controls[e.currentTarget.dataset.id].index + 1)) { this.data.controls[e.currentTarget.dataset.id].index++; } this.setData({ controls: this.data.controls }) this.triggerEvent('error', { target: e.currentTarget, message: e.detail.errMsg }, { bubbles: true, composed: true }); }, [代码] 添加加载提示 可以在组件的插槽中放入加载提示信息或动画,在加载完成后会将 [代码]slot[代码] 的内容渐隐,将富文本内容渐显,提高用户体验,避免在加载过程中一片空白。 最终效果 经过一个多月的改进,目前实现了这个功能丰富,无层数限制,渲染效果好,轻量化(30.0KB),效率高,前后端通用的富文本插件,体验小程序的用户数已经突破1k啦,欢迎使用和体验 [图片] github 地址 npm 地址 总结 以上就是我在开发这样一个富文本插件的过程大致介绍,希望对大家有所帮助;本人在校学生,水平所限,不足之处欢迎提意见啦! [图片]
2020-12-27 - CryptoJS 加解密使用示例
[代码]// SHA1 加密[代码] [代码]var[代码] [代码]value = [代码][代码]"123456"[代码][代码];[代码] [代码]var[代码] [代码]wordArray = CryptoJS.SHA1(value);[代码] [代码]var[代码] [代码]str = wordArray.toString(CryptoJS.enc.Hex);[代码] [代码]// HmacSHA1加密[代码] [代码]var[代码] [代码]message = [代码][代码]"message"[代码][代码];[代码] [代码]var[代码] [代码]key = [代码][代码]"key"[代码][代码];[代码] [代码]var[代码] [代码]wordArray = CryptoJS.HmacSHA1(message, key);[代码] [代码]var[代码] [代码]str = wordArray.toString(CryptoJS.enc.Hex);[代码] [代码]// md5 加密[代码] [代码]var[代码] [代码]md5 = CryptoJS.MD5([代码][代码]"md5"[代码][代码]).toString();[代码] [代码]// AES 加解密 开始[代码] [代码]/**[代码] [代码] [代码][代码]* //AES 解密方法[代码] [代码] [代码][代码]* word 字符串[代码] [代码] [代码][代码]*/[代码] [代码]const AES_JIA = [代码][代码]function[代码] [代码](word, key, iv) {[代码] [代码] [代码][代码]let encryptedHexStr = CryptoJS.enc.Hex.parse(word);[代码] [代码] [代码][代码]let srcs = CryptoJS.enc.Base64.stringify(encryptedHexStr);[代码] [代码] [代码][代码]let decrypt = CryptoJS.AES.decrypt(srcs, key, { iv: iv, mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 });[代码] [代码] [代码][代码]let decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);[代码] [代码] [代码][代码]return[代码] [代码]decryptedStr.toString();[代码] [代码]}[代码] [代码]/**[代码] [代码] [代码][代码]* //AES 加密方法[代码] [代码] [代码][代码]* word 字符串[代码] [代码] [代码][代码]*/[代码] [代码]const AES_JIE = [代码][代码]function[代码] [代码](word, key, iv) {[代码] [代码] [代码][代码]let srcs = CryptoJS.enc.Utf8.parse(word);[代码] [代码] [代码][代码]let encrypted = CryptoJS.AES.encrypt(srcs, key, {[代码] [代码] [代码][代码]iv: iv,[代码] [代码] [代码][代码]mode: CryptoJS.mode.ECB,[代码] [代码] [代码][代码]padding: CryptoJS.pad.Pkcs7[代码] [代码] [代码][代码]});[代码] [代码] [代码][代码]return[代码] [代码]encrypted.ciphertext.toString().toUpperCase();[代码] [代码]}[代码] [代码]const word = [代码][代码]"字符串格式"[代码][代码]; [代码][代码]// 字符串格式[代码] [代码]const key = CryptoJS.enc.Utf8.parse([代码][代码]"1234567890123456"[代码][代码]); [代码][代码]//十六位十六进制数作为密钥 ,十六位,十六位,不要 误以为 1234567890123456 == 123 是行得通的 字符长度16不等于 3,除非 key = 123[代码] [代码]const iv = CryptoJS.enc.Utf8.parse([代码][代码]''[代码][代码]); [代码][代码]//十六位十六进制数作为密钥偏移量[代码] [代码]var[代码] [代码]ctext = AES_JIA(word, key, iv);[代码] [代码]console.log([代码][代码]"ctext=>"[代码][代码], ctext); [代码][代码]// AES 加密[代码] [代码]var[代码] [代码]ptext = AES_JIE(ctext, key, iv);[代码] [代码]console.log([代码][代码]"ptext=>"[代码][代码], ptext); [代码][代码]// AES 解密[代码] [代码]// AES 加解密 结束[代码] [代码]//DES 加密[代码][代码]function[代码] DES_JIA[代码](message, key, iv) {[代码][代码] [代码][代码]var[代码] [代码]keyHex = CryptoJS.enc.Utf8.parse(key);[代码][代码] [代码][代码]var[代码] [代码]encrypted = CryptoJS.DES.encrypt(message, keyHex, {[代码][代码] [代码][代码]iv: iv,[代码][代码] [代码][代码]mode: CryptoJS.mode.ECB,[代码][代码] [代码][代码]padding: CryptoJS.pad.Pkcs7[代码][代码] [代码][代码]});[代码][代码] [代码][代码]return[代码] [代码]encrypted.toString();[代码][代码]}[代码] [代码]//DES 解密[代码][代码]function[代码] [代码]DES_JIE(ciphertext, key, iv) {[代码][代码] [代码][代码]var[代码] [代码]keyHex = CryptoJS.enc.Utf8.parse(key);[代码][代码] [代码][代码]// direct decrypt ciphertext[代码][代码] [代码][代码]var[代码] [代码]decrypted = CryptoJS.DES.decrypt({[代码][代码] [代码][代码]ciphertext: CryptoJS.enc.Base64.parse(ciphertext)[代码][代码] [代码][代码]}, keyHex, {[代码][代码] [代码][代码]iv: iv,[代码][代码] [代码][代码]mode: CryptoJS.mode.ECB,[代码][代码] [代码][代码]padding: CryptoJS.pad.Pkcs7[代码][代码] [代码][代码]});[代码][代码] [代码][代码]return[代码] [代码]decrypted.toString(CryptoJS.enc.Utf8);[代码][代码]}[代码] [代码]var[代码] [代码]des_text = DES_JIA(word, key, iv);[代码][代码]console.log([代码][代码]"des_text=>"[代码][代码], des_text); [代码][代码]// des 加密[代码] [代码]var[代码] [代码]ntext = DES_JIE(des_text, key, iv);[代码][代码]console.log([代码][代码]"ntext=>"[代码][代码], ntext); [代码][代码]// des 解密[代码] 调试(SHA1 加密)图片示例: [图片] 参考资料: https://cryptojs.gitbook.io/docs/ https://www.bootcdn.cn/crypto-js/
2019-05-29 - 自定义标题栏
使用效果 [图片][图片][图片][图片] 使用方法 属性介绍 属性名 类型 默认值 是否必须 说明 menuSrc String ‘’ 否 按钮图片地址 bgImgSrc String ‘’ 否 背景图片地址 bgImgMode String aspectFill 否 背景图片的显示模式 title String ‘’ 否 标题 titleTextColor String ‘’ 否 字体和按钮以及loading图标的颜色,按钮和loading暂时只有黑白2色 backgroundColor String ‘’ 否 整个标题栏的背景颜色 loading Boolean false 否 是否是加载状态 backProxy Boolean false 否 是否重写了返回键 标题栏中属性的默认数据会自动获取json配置以及系统的默认数据,如果不需要动态更改样式,可以在json中设置,组件中同样起作用 事件介绍 属性名 detail NaviBack 返回的逻辑方法 MenuTap 按钮的点击事件 [代码]"usingComponents": { "toolBar": "/component/toolbar" }, [代码] [代码]<toolBar menuSrc='/image/menu_white.png' bindMenuTap='onMenuTap' bgImgSrc='/image/navi-bg.jpg' /> [代码] 高度说明: 为了方便适配,这里给出自定义标题栏的计算公式: const MenuRect = wx.getMenuButtonBoundingClientRect() const statusBarHeight = wx.getSystemInfoSync().statusBarHeight; const height = (MenuRect.top - statusBarHeight) * 2 + MenuRect.height +MenuRect.top Github地址:https://github.com/Aracy/wx-mini-navigationbar
2019-05-21 - 微信小程序开发经验+, 可能需要了解的一些骚操作
扩展Page / App? 自定义navigationBar? 冗余授权代码? local/session/expire缓存管理混乱? … 总结了一些开发微信小程序过程中遇到问题的解决方式/经验分享,另外共享几个通用组件. star+✨ https://github.com/JoweiBlog/wechat-miniprogram-dev
2019-05-22 - 干货--02 余小浪
哈喽 我又来了 这是我第二次分享文章了 希望能够帮助大家 也希望大家喜欢~ 第一个 image组件中的 mode=“aspectFill” 属性 这个属性是等比例缩放 如果你的图片是这个属性的需要注意注意注意 图片渲染完成后 再等比例缩放 及 先渲染 再等比缩放 例子: 当你要获取这个图片距离顶部的距离是 需要使用 wx.createSelectorQuery来来找到这个标签并获取到这个标签的参数 一般会写在 onReady() 生命周期钩子函数里 但是 问题就在这个时候出 现了 我获取的标签数据 不是 实际的数据 而是 图片没有缩放的数据 解决这个问题的时候 我使用了 setTimeout 函数 把时间设置为500 即 半秒后 再获取图片的标签的 参数 这时候 获取到的数据就是正确的数据了 暂时没有测试不写等待时间 有兴趣大家可以试一下 第二个 前端绘制海报性能优化 绘制海报我们用到了canvas 绘制海报的前 提是 绘制的素材要下载到本地 如果我们在绘制的时候下载素材 这个时 候 绘制的进度就会变慢 优化的思想如下 B页面是绘制海报的 A页面 点击某个按钮 进入到 B页面 那么我们就在 渲染A页面的时候 就下载素材呢 等到了B页面 素材都已经有了 直接使用,绘制效果会非常好 甚至是 秒绘制完成 在B页面onUnload函数内 清除下载文件的缓存 避免缓存太多 第三 字符串10 减去 数字0 最后 变成了 数字 10 let string = “10” string - 0 此时 string 就是 数字 10 类型是number // JS的隐式转换 很常用的一种改变数据类型的方式 0 的 布尔值 是 false 第四 防止数据抖动的方法 数据抖动 说白了 就是 一个按钮有一个事件 然后用户在很短的事件内重复点击 类似的有 购买物品 提交完成按钮 这些 解决方法 先声明一个变量 值为true 当做锁 当执行函数的时候 把这个锁变成 false 那么这个函数就被锁死了 只有这个函数完成所有操作的时候 再把锁变成true 此刻用户才可以再次真正的点击 代码如下: [图片] [图片] 今天的分享就到这里了 如果喜欢请大家动动小手指 点个赞吧 欢迎各位大佬亲临指导 如果有问题请及时指出 我会第一时间修改的 嘻嘻
2019-05-21 - 咱这个社区今天擦粉了--分享一个表单验证组件
打开社区,突然发现社区变白了,应该是擦粉了。🤣🤣🤣 分享一个表单验证组件,可提供数据验证和界面错误显示,效果截图如下: [图片][图片] 传送门在这里 https://github.com/ikrong/miniprogram-form-validator 下面格式不知道为什么都乱了,这什么鬼编辑器😒😒😒 安装 [代码]npm install miniprogram-form-validator // or yarn add miniprogram-form-validator [代码] 引入 在app.json中引入可全局使用 引入之后需要点击小程序开发工具的 【工具>构建npm】, 否则会报错的 [代码]{ "usingComponents": { "form-validator": "miniprogram-form-validator/form-validator", "form-tip": "miniprogram-form-validator/form-tip" } } [代码] [代码]<form-validator id="form" formGroup="{{formGroup}}" formData="{{formData}}"> <form-tip name="id"></form-tip> </form-validator> [代码] 验证器的字段含义 [代码]{ required: true, // 是否必填 message: "", // 出错后的提示信息 type: "", // 内置验证方法 regexp: RegExp, // 正则表达式 validator: Function // 自定义验证方法,返回 boolean 或者 Promise<boolean> } [代码] [代码]Page({ data:{ formGroup:{ id:[ { required:true }, { validator:(value,name)=>Promise<Boolean> }, ] }, formData:{ id:"", } }, async validate(){ let result = await this.selectComponents("#form").validate(); // 验证通过了 } }) [代码]
2019-05-22 - 完美解决小程序session问题
小程序不像web浏览器有cookie机制,在默认使用cookie存sessionid的机制下,后台将无法正常使用session功能,如果正确使用session呢,提供两个方案。 [代码]1、将sessionid通过url进行传递 用户每次登录成功后将生成的sessionid值使用参数回传到客户端, 客户端接到sessionid后保存到本地, 在发起网络请求的底层接口中默认自动带上sessionid=本地存储的sessionid值。 需要配合服务器一起更改,服务器后端默认使用cookie机制 2、无缝对接cookie, 将服务器的set-cookie值保存到本地,再请求的时候模拟浏览器头部信息并带上保存的cookie信息 1)保存cookie值: _XHR('login',{'code':res.code}).then(function( ret ){ ret.header["Set-Cookie"] != undefined && wx.setStorageSync("cookie", ret.header["Set-Cookie"]); }); 2)请求的时候自动带上cookie信息 var header={}; header = { 'content-type': 'application/x-www-form-urlencoded' }; var cookie = wx.getStorageSync("cookie"); if( url != 'login' && !isNull( cookie ) ){ header['cookie'] = cookie; } 将header 赋值到 request的header内 wx.request({ url: qryDomian + url + '.html', data: _data, method: 'POST', header: header, dataType:'json' ...... 第二种方案服务器无需做任务操作。[代码]
2019-05-20 - Licia 支持小程序的 JS 工具库
导语 Licia 是一套在开发中实践积累起来的实用 JavaScript 工具库。该库目前拥有超过 300 个模块,同时支持浏览器、node 及小程序运行环境,提供了包括日期格式化、md5、颜色转换等实用模块,可以极大地提高开发效率。 前言 因为小程序运行的是 JavaScript 代码,传统前端所使用的 JS 库理应也能够被用在小程序中才对。然而,经过实际测试,你会发现有相当一部分 npm 包是无法直接在小程序中跑起来的。比如前端工程师十分常用的 lodash,在小程序中引入会报错。 为什么会这样? 主要原因就是绝大部分库的开发者在设计时只会考虑两种运行环境,浏览器和 node,而小程序并不会在其考虑范围内。因此,只要开发者的 JS 代码使用了只有浏览器与 node 中才有的接口,如 DOM 操作、文件读写等,该库就不能正常地运行在小程序环境中。除此之外,假如他们使用了小程序禁用的功能,例如全局变量与动态代码执行,这时候代码跑在小程序环境也会出错。 使用 使用 npm 安装 1、 安装 npm 包 [代码]npm i miniprogram-licia --save [代码] 2、点击开发者工具中的菜单栏:工具 --> 构建 npm 3、直接在代码中引入使用 [代码]const licia = require('miniprogram-licia'); licia.md5('licia'); // -> 'e59f337d85e9a467f1783fab282a41d0' licia.safeGet({a: {b: 1}}, 'a.b'); // -> 1 [代码] 生成定制化 util.js 使用 npm 包的方式会将所有功能引入到代码包中,大概会增加 100 kb 的大小。如果你只想引入所需脚本,可以使用在线工具生成定制化 util 库。 1、访问 https://licia.liriliri.io/builder.html 2、输入需要的模块名,点击生成下载 util.js。 3、将生成的工具库拷贝到小程序项目任意目录下然后直接引入使用。 [代码]const util = require('../lib/util'); util.wx.getStorage({ key: 'test' }).then(res => console.log(res.data)); [代码] 优点 1、目前拥有 270 多个模块可在小程序中正常运行,而 underscore 只有 120 个函数左右。 2、与 lodash 相比增加了不少更加实用的函数,比如 md5、atob、btoa、Emitter、dateFormat 等。 3、可以直接在小程序中引入运行,不像 lodash 需要进行一定的修改才能正常跑在小程序中。 4、定制化生成可以使用更小体积的工具库,这在限制了代码包大小的小程序中十分有用。 附录 这里只简单列出函数及其功能介绍,详细的用法请访问官网查看。 注:模块名右边有小程序图标即表明可以在小程序中使用。 Class: 创建 JavaScript 类。 Color: 颜色转换。 Dispatcher: Flux 调度器。 Emitter: 提供观察者模式的 Event emitter 类。 Enum: Enum 类实现。 JsonTransformer: JSON 转换器。 LinkedList: 双向链表实现。 Logger: 带日志级别的简单日志库。 Lru: 简单 LRU 缓存。 Promise: 轻量 Promise 实现。 PseudoMap: 类似 es6 的 Map,不支持遍历器。 Queue: 队列数据结构。 QuickLru: 不使用链表的 LRU 实现。 ReduceStore: 简单类 redux 状态管理。 Stack: 栈数据结构。 State: 简单状态机。 Store: 内存存储。 Tween: JavaScript 补间动画库。 Url: 简单 url 操作库。 Validator: 对象属性值校验。 abbrev: 计算字符串集的缩写集合。 after: 创建一个函数,只有在调用 n 次后才会调用一次。 allKeys: 获取对象的所有键名,包括自身的及继承的。 arrToMap: 将字符串列表转换为映射。 atob: window.atob,运行在 node 环境时使用 Buffer 进行模拟。 average: 获取数字的平均值。 base64: base64 编解码。 before: 创建一个函数,只能调用少于 n 次。 binarySearch: 二分查找实现。 bind: 创建一个绑定到指定对象的函数。 btoa: window.btoa,运行在 node 环境时使用 Buffer 进行模拟。 bubbleSort: 冒泡排序实现。 bytesToStr: 将字节数组转换为字符串。 callbackify: 将返回 Promise 的函数转换为使用回调的函数。 camelCase: 将字符串转换为驼峰式。 capitalize: 将字符串的第一个字符转换为大写,其余字符转换为小写。 castPath: 将值转换为属性路径数组。 centerAlign: 字符串居中。 char: 根据指定的整数返回 unicode 编码为该整数的字符。 chunk: 将数组拆分为指定长度的子数组。 clamp: 将数字限定于指定区间。 className: 合并 class。 clone: 对指定对象进行浅复制。 cloneDeep: 深复制。 cmpVersion: 比较版本号。 combine: 创建一个数组,用一个数组的值作为其键名,另一个数组的值作为其值。 compact: 返回数组的拷贝并移除其中的虚值。 compose: 将多个函数组合成一个函数。 concat: 将多个数组合并成一个数组。 contain: 检查数组中是否有指定值。 convertBase: 对数字进行进制转换。 createAssigner: 用于创建 extend,extendOwn 和 defaults 等模块。 curry: 函数柯里化。 dateFormat: 简单日期格式化。 debounce: 返回函数的防反跳版本。 decodeUriComponent: 类似 decodeURIComponent 函数,只是输入不合法时不抛出错误并尽可能地对其进行解码。 defaults: 填充对象的默认值。 define: 定义一个模块,需要跟 use 模块配合使用。 defineProp: Object.defineProperty(defineProperties) 的快捷方式。 delay: 在指定时长后执行函数。 detectBrowser: 使用 ua 检测浏览器信息。 detectMocha: 检测是否有 mocha 测试框架在运行。 detectOs: 使用 ua 检测操作系统。 difference: 创建一个数组,该数组的元素不存在于给定的其它数组中。 dotCase: 将字符串转换为点式。 each: 遍历集合中的所有元素,用每个元素当做参数调用迭代器。 easing: 缓动函数,参考 http://jqueryui.com/ 。 endWith: 检查字符串是否以指定字符串结尾。 escape: 转义 HTML 字符串,替换 &,<,>,",`,和 ’ 字符。 escapeJsStr: 转义字符串为合法的 JavaScript 字符串字面量。 escapeRegExp: 转义特殊字符用于 RegExp 构造函数。 every: 检查是否集合中的所有元素都能通过真值检测。 extend: 复制多个对象中的所有属性到目标对象上。 extendDeep: 类似 extend,但会递归进行扩展。 extendOwn: 类似 extend,但只复制自己的属性,不包括原型链上的属性。 extractBlockCmts: 从源码中提取块注释。 extractUrls: 从文本中提取 url。 fibonacci: 计算斐波那契数列中某位数字。 fileSize: 将字节数转换为易于阅读的形式。 fill: 在数组指定位置填充指定值。 filter: 遍历集合中的每个元素,返回所有通过真值检测的元素组成的数组。 find: 找到集合中第一个通过真值检测的元素。 findIdx: 返回第一个通过真值检测元素在数组中的位置。 findKey: 返回对象中第一个通过真值检测的属性键名。 findLastIdx: 同 findIdx,只是查找顺序改为从后往前。 flatten: 递归拍平数组。 fnParams: 获取函数的参数名列表。 format: 使用类似于 printf 的方式来格式化字符串。 fraction: 转换数字为分数形式。 freeze: Object.freeze 的快捷方式。 freezeDeep: 递归进行 Object.freeze。 gcd: 使用欧几里德算法求最大公约数。 getUrlParam: 获取 url 参数值。 has: 检查属性是否是对象自身的属性(原型链上的不算)。 hslToRgb: 将 hsl 格式的颜色值转换为 rgb 格式。 identity: 返回传入的第一个参数。 idxOf: 返回指定值第一次在数组中出现的位置。 indent: 对文本的每一行进行缩进处理。 inherits: 使构造函数继承另一个构造函数原型链上的方法。 insertionSort: 插入排序实现。 intersect: 计算所有数组的交集。 intersectRange: 计算两个区间的交集。 invert: 生成一个新对象,该对象的键名和键值进行调换。 isAbsoluteUrl: 检查 url 是否是绝对地址。 isArgs: 检查值是否是参数类型。 isArr: 检查值是否是数组类型。 isArrBuffer: 检查值是否是 ArrayBuffer 类型。 isArrLike: 检查值是否是类数组对象。 isBool: 检查值是否是布尔类型。 isBrowser: 检测是否运行于浏览器环境。 isClose: 检查两个数字是否近似相等。 isDataUrl: 检查字符串是否是有效的 Data Url。 isDate: 检查值是否是 Date 类型。 isEmail: 简单检查值是否是合法的邮件地址。 isEmpty: 检查值是否是空对象或空数组。 isEqual: 对两个对象进行深度比较,如果相等,返回真。 isErr: 检查值是否是 Error 类型。 isEven: 检查数字是否是偶数。 isFinite: 检查值是否是有限数字。 isFn: 检查值是否是函数。 isGeneratorFn: 检查值是否是 Generator 函数。 isInt: 检查值是否是整数。 isJson: 检查值是否是有效的 JSON。 isLeapYear: 检查年份是否是闰年。 isMap: 检查值是否是 Map 对象。 isMatch: 检查对象所有键名和键值是否在指定的对象中。 isMiniProgram: 检测是否运行于微信小程序环境中。 isMobile: 使用 ua 检测是否运行于移动端浏览器。 isNaN: 检测值是否是 NaN。 isNative: 检查值是否是原生函数。 isNil: 检查值是否是 null 或 undefined,等价于 value == null。 isNode: 检测是否运行于 node 环境中。 isNull: 检查值是否是 Null 类型。 isNum: 检测值是否是数字类型。 isNumeric: 检查值是否是数字,包括数字字符串。 isObj: 检查值是否是对象。 isOdd: 检查数字是否是奇数。 isPlainObj: 检查值是否是用 Object 构造函数创建的对象。 isPrime: 检查整数是否是质数。 isPrimitive: 检测值是否是字符串,数字,布尔值或 null。 isPromise: 检查值是否是类 promise 对象。 isRegExp: 检查值是否是正则类型。 isRelative: 检查路径是否是相对路径。 isSet: 检查值是否是 Set 类型。 isSorted: 检查数组是否有序。 isStr: 检查值是否是字符串。 isTypedArr: 检查值是否 TypedArray 类型。 isUndef: 检查值是否是 undefined。 isUrl: 简单检查值是否是有效的 url 地址。 isWeakMap: 检查值是否是 WeakMap 类型。 isWeakSet: 检查值是否是 WeakSet 类型。 kebabCase: 将字符串转换为短横线式。 keyCode: 键码键名转换。 keys: 返回包含对象自身可遍历所有键名的数组。 last: 获取数组的最后一个元素。 linkify: 将文本中的 url 地址转换为超链接。 longest: 获取数组中最长的一项。 lowerCase: 转换字符串为小写。 lpad: 对字符串进行左填充。 ltrim: 删除字符串头部指定字符或空格。 map: 对集合的每个元素调用转换函数生成与之对应的数组。 mapObj: 类似 map,但针对对象,生成一个新对象。 matcher: 传入对象返回函数,如果传入参数中包含该对象则返回真。 max: 获取数字中的最大值。 md5: MD5 算法实现。 memStorage: Web Storage 接口的纯内存实现。 memoize: 缓存函数计算结果。 mergeSort: 归并排序实现。 methods: 获取对象中所有方法名。 min: 获取数字中的最小值。 moment: 简单的类 moment.js 实现。 ms: 时长字符串与毫秒转换库。 negate: 创建一个将原函数结果取反的函数。 nextTick: 能够同时运行在 node 和浏览器端的 next tick 实现。 noop: 一个什么也不做的空函数。 normalizeHeader: 标准化 HTTP 头部名。 normalizePath: 标准化文件路径中的斜杠。 now: 获取当前时间戳。 objToStr: Object.prototype.toString 的别名。 omit: 类似 pick,但结果相反。 once: 创建只能调用一次的函数。 optimizeCb: 用于高效的函数上下文绑定。 pad: 对字符串进行左右填充。 pairs: 将对象转换为包含【键名,键值】对的数组。 parallel: 同时执行多个函数。 parseArgs: 命令行参数简单解析。 partial: 返回局部填充参数的函数,与 bind 模块相似。 pascalCase: 将字符串转换为帕斯卡式。 perfNow: 高精度时间戳。 pick: 过滤对象。 pluck: 提取数组对象中指定属性值,返回一个数组。 precision: 获取数字的精度。 promisify: 转换使用回调的异步函数,使其返回 Promise。 property: 返回一个函数,该函数返回任何传入对象的指定属性。 query: 解析序列化 url 的 query 部分。 quickSort: 快排实现。 raf: requestAnimationFrame 快捷方式。 random: 在给定区间内生成随机数。 randomItem: 随机获取数组中的某项。 range: 创建整数数组。 rc4: RC4 对称加密算法实现。 reduce: 合并多个值成一个值。 reduceRight: 类似于 reduce,只是从后往前合并。 reject: 类似 filter,但结果相反。 remove: 移除集合中所有通过真值检测的元素,返回包含所有删除元素的数组。 repeat: 重复字符串指定次数。 restArgs: 将给定序号后的参数合并成一个数组。 rgbToHsl: 将 rgb 格式的颜色值转换为 hsl 格式。 root: 根对象引用,对于 nodeJs,取 [代码]global[代码] 对象,对于浏览器,取 [代码]window[代码] 对象。 rpad: 对字符串进行右填充。 rtrim: 删除字符串尾部指定字符或空格。 safeCb: 创建回调函数,内部模块使用。 safeDel: 删除对象属性。 safeGet: 获取对象属性值,路径不存在时不报错。 safeSet: 设置对象属性值。 sample: 从集合中随机抽取部分样本。 selectionSort: 选择排序实现。 shuffle: 将数组中元素的顺序打乱。 size: 获取对象的大小或类数组元素的长度。 sleep: 使用 Promise 模拟暂停方法。 slice: 截取数组的一部分生成新数组。 snakeCase: 转换字符串为下划线式。 some: 检查集合中是否有元素通过真值检测。 sortBy: 遍历集合中的元素,将其作为参数调用函数,并以得到的结果为依据对数组进行排序。 spaceCase: 将字符串转换为空格式。 splitCase: 将不同命名式的字符串拆分成数组。 splitPath: 将路径拆分为文件夹路径,文件名和扩展名。 startWith: 检查字符串是否以指定字符串开头。 strHash: 使用 djb2 算法进行字符串哈希。 strToBytes: 将字符串转换为字节数组。 stringify: JSON 序列化,支持循环引用和函数。 stripAnsi: 清除字符串中的 ansi 控制码。 stripCmt: 清除源码中的注释。 stripColor: 清除字符串中的 ansi 颜色控制码。 stripHtmlTag: 清除字符串中的 html 标签。 sum: 计算数字和。 swap: 交换数组中的两项。 template: 将模板字符串编译成函数用于渲染。 throttle: 返回函数的节流阀版本。 timeAgo: 将时间格式化成多久之前的形式。 timeTaken: 获取函数的执行时间。 times: 调用目标函数 n 次。 toArr: 将任意值转换为数组。 toBool: 将任意值转换为布尔值。 toDate: 将任意值转换为日期类型。 toInt: 将任意值转换为整数。 toNum: 将任意值转换为数字。 toSrc: 将函数转换为源码。 toStr: 将任意值转换为字符串。 topoSort: 拓扑排序实现。 trim: 删除字符串两边指定字符或空格。 tryIt: 在 try catch 块中运行函数。 type: 获取 JavaScript 对象的内部类型。 types: 仅用于生成 ts 定义文件。 ucs2: UCS-2 编解码。 unescape: 和 escape 相反,转义 HTML 实体回去。 union: 返回传入所有数组的并集。 uniqId: 生成全局唯一 id。 unique: 返回数组去重后的副本。 unzip: 与 zip 相反。 upperCase: 转换字符串为大写。 upperFirst: 将字符串的第一个字符转换为大写。 use: 使用 define 创建的模块。 utf8: UTF-8 编解码。 values: 返回对象所有的属性值。 vlq: vlq 编解码。 waitUntil: 等待直到条件函数返回真值。 waterfall: 按顺序执行函数序列。 wrap: 将函数封装到包裹函数里面, 并把它作为第一个参数传给包裹函数。 wx: 小程序 wx 对象的 promise 版本。 zip: 将每个数组中相应位置的值合并在一起。
2019-05-07 - DatePicker 年月日时分秒 任你选
DatePicker 微信上的时间选择,有的时候你会发现,你不能同时选择日期和时间,而且时间不能选到秒。DatePicker让你想选什么选什么… Mode DatePicker分为四个mode:YMDhms(年月日时分秒)、YMD(年月日)、MD(月日)、hm(时分)。 我自己觉得用起来很爽快。 效果图 mode:YMDhms (年月日时分秒) [图片] mode:YMD(年月日) [图片] mode:MD (月日) [图片] mode:hm (时分) [图片] gitHub地址
2019-05-11 - 【小程序取值和传值】—你也可能遇到的坑系列
前言 小程序真的很好用,非常的便捷,并且我们可以很轻松的开发属于自己的一款小程序,但是在我们开发写代码的时候难免会遇到一些小坑,然后就是各种的疑问???我整理一些我遇到的坑,说不定你也遇到过哈哈哈 取值和传值 在我们开发一个程序的时候,大概率会涉及到要得到一些节点的值或是需要在页面跳转的时候传一些值过去以完成一些事情,我总结三点 普通的取值 页面传值 from表单取值 虽然看上去很简单但是偶尔会有一些小坑等着我们 普通的取值 通常情况下我们都是先给组件绑定事件,按照文档的说法,如无特殊说明,当组件触发事件时,逻辑层绑定该事件的处理函数会收到一个事件对象。 比如我们给一个组件绑定了一个点击事件叫getValue,然后我们有一个函数如下,其中event就是事件对象 [代码]<view bindtap="getValue"></view> [代码] [代码]getValue:function(event){ // do someing } [代码] 这个event事件对象是基本的对象事件他其中包括了一些属性,对于我们获得想要的值很重要 [图片] 🆗,到这之后我们可以知道target,和currentTarget对我们很重要了,所以接下来继续看看文档给了我们什么信息 target [图片] currentTarget [图片] 初看可能觉得两个没什么区别,但是这就是坑啊,这两个是有一些区别的,target是触发事件的源组件,也就是说你在button上绑定一个事件那么target就是指向这个button不会变的,而currentTarget就不一样,它指向的是触发事件监听的对象, 注意理解 触发事件监听”的对象与“添加(注册)监听事件”的对象是不一样的!前者是能够触发该事件但没有绑定事件,后者指绑定了事件 填坑 1、如果绑定的事件所在组件没有子元素,则用e.target===e.currentTarget一样; 2、如果事件绑定在父元素中,且该父元素有子元素,当用e.currentTarget时,不管点击父元素所在区域还是子元素(当前事件),都正确执行,若用e.target时,点击父元素所在区域无错,点击子元素区域,执行报错,报错的原因是事件没绑定在子元素上,是在父元素上,子元素要用e.currentTarget才正确 上面内容中有引用此博客 https://blog.csdn.net/syleapn/article/details/81289337 官方文档传送 https://developers.weixin.qq.com/miniprogram/dev/framework/view/wxml/event.html 上面说了很多,但是还没有说到底怎么取值,其实看完上面也差不多了,下面需要提的是dataset 文档的解释: 在组件中可以定义数据,这些数据将会通过事件传递给 SERVICE。 书写方式: 以data-开头,多个单词由连字符-链接,不能有大写(大写会自动转成小写)如data-element-type,最终在 event.currentTarget.dataset 中会将连字符转成驼峰elementType。 简单的理解就是我们可以把想要获取的值通过data-定义到组件上,当我们在触发一个事件的时候我们可以通过event.currentTarget.dataset.XX来获取 [代码]// 像这样 <view> <button bindtap='info' data-value='5555'>点击</button> </view> [代码] [代码]// e和event等价 info:function(e){ console.log('value:'+e.currentTarget.dataset.value) } [代码] 结果:当我们点击按钮的时候就可以得到我们预先定义好的值了 页面传值 页面传值的使用还是比较多的,特别是我们需要做一些详情页面的时候,经常涉及到需要将上一个页面的某一些值带到第二个页面来。这个坑要少一点,我们一步一步来说吧 首先说跳转吧 仅有两种,其他方法欢迎讨论 wx.navigateTo(Object object) navigator 传参数 虽然有两种跳转的方式,但是它们传递参数的方式是一样的,url后拼接?id(参数名字)=要传递的值 注意 (如果多个参数用&分开 &name=value&…….) [代码]<navigator url="pages/detail/detail?value="123"> 跳转 </navigator> [代码] [代码]wx.navigateTo({ url:"pages/detail/detail?value="123", success: function (res) { // success }, fail: function () { // fail }, complete: function () { // complete } }) [代码] 获得参数 在跳转到的界面的一些生命周期的函数中有一个options,它是包含url地址中参数的对象,可以通过它直接点获取。 [代码]onLoad:function(options){ console.log(options.value) } [代码] from表单取值 这里首先要铺垫一下deatil deatil是event事件对象的一个属性,它包括一些额外的信息 ok,接下来要说取值了,常规的做法是通过 <form bindsubmit=“formSubmit”> 与 <button formType=“submit”> 标签配合使用,然后给input一个name属性,我们在js中就可以使用e.deatil.value.name来获取了 [代码]<form bindsubmit="formSubmit"> <input name="detail" placeholder="详情地址" /> <input name="realname" placeholder="收件人姓名" /> <input name="mobile" placeholder="手机号码" type="number"/> <button formType="submit" type="primary">Submit</button> </form> [代码] 对新手来说这里有一点小坑,新手可能会有疑惑 为什么没有给button绑定事件呢,是不是需要在绑定一个事件,其实不用,<form bindsubmit=“formSubmit”> 已经绑定了事件,我们在 js 中只需要写一个叫 formSubmit 的函数就好了 [代码]formSubmit: function(e) { // detail var detail = e.detail.value.detail; // realname var realname = e.detail.value.realname; // mobile var mobile = e.detail.value.mobile; } [代码] 此处有引用 https://www.cnblogs.com/lrgupup/p/7609118.html 结语 好记性不如烂笔头,记录一下自己犯过的一些小错❤
2020-03-24 - 关于使用了层级过高的组件后,使用Input被遮挡的问题解决方法!(我真是个天才~~~~)
用cover-view 来显示,input来输入,通过bindinput来赋值,用cover-view来覆盖input的位置,同时把cover-view的背景颜色设置透明,这样既不会被其它组件盖住,也能显示出input的光标出来。
2019-05-08 - 微信小程序swiper高度动态适配(子元素高度不固定)
示例代码地址 https://github.com/s568774056/swipe.git 对于整页都是swiper的情况下。例如下面这张图: [图片] 则可以使用如下css [代码] [代码] [代码]swiper,swiper-item{[代码] [代码] [代码][代码]height[代码][代码]: [代码][代码]100[代码][代码]vh [代码][代码]!important[代码][代码];[代码][代码]}[代码] [代码] [代码] [代码]或者 [代码] [代码] [代码] [代码][代码] [代码][代码] swiper,swiper-item{ height: calc(100vh - 75rpx) !important; } [代码][代码] [代码] [代码] 对于swiper占据部分高度的情况下。 [图片] 使用如下代码 原理为在[代码]swiper-item[代码][代码][代码]的最上面和最下面插入空view,并利用wx api获取两个之间的高度差,然后设置给[代码]swiper[代码]。 细节方面需要自己调整下。为什么小程序不把这个组件做好呢?还得自己计算- -! <swiper class='hide' bindanimationfinish="swiperChange" style="height:{{swiper_height}};" current="{{isIndex}}"> <swiper-item wx:for="{{roomList}}" wx:for-item='room' wx:for-index="index"> <view id="start{{index}}" class='start-view'></view> <block wx:for="{{imgUrls}}" wx:for-item='path' wx:for-index="img-index"> <image mode="aspectFill" src="{{path}}" /> </block> <view id="end{{index}}" class='start-view'></view> </swiper-item> </swiper> [代码][代码][代码][代码] swiper { margin-top: 45rpx; } Page({ data: { roomList: ['Room1', 'Room2', 'Room3'], imgUrls: [ 'https://images.unsplash.com/photo-1551334787-21e6bd3ab135?w=640', 'https://images.unsplash.com/photo-1551214012-84f95e060dee?w=640', 'https://images.unsplash.com/photo-1551446591-142875a901a1?w=640' ], swiper_height: 0, isIndex:0 }, onLoad: function () { this.autoHeight(); }, changeNavBar: function (e) { this.setData({ isIndex: e.detail }); }, swiperChange: function (e) { this.setData({ isIndex: e.detail.current }); this.autoHeight(); }, autoHeight() { let { isIndex } = this.data; wx.createSelectorQuery() .select('#end' + isIndex).boundingClientRect() .select('#start' + isIndex).boundingClientRect().exec(rect => { let _space = rect[0].top - rect[1].top; _space = _space + 'px'; this.setData({ swiper_height: _space }); }) } }) 参考文章https://developers.weixin.qq.com/community/develop/doc/00008aaf4a473056d1c69a8b253c04
2019-09-04 - 那些被忽略的盒子模型小知识
那些被忽略的盒子模型小知识 本文是笔者在学习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 - 自定义导航栏所有机型的适配方案
写在前面的话 大家看到这个文章时一定会感觉这是在炒剩饭,社区中已经有那么多分享自定义导航适配的文章了,为什么我还要再写一个呢? 主要原因就是,社区中大部分的适配方案中给出的大小是不精确的,并不能完美适配各种场景。 社区中大部分文章给到的值是 iOS -> 44px , Android -> 48px 思路 正常来讲,iOS和Android下的胶囊按钮的位置以及大小都是相同且不变的,我们可以通过胶囊按钮的位置和大小再配合 wx.getSystemInfo 或者 wx.getSystemInfoSync 中得到的 [代码]statusBarHeight[代码] 来计算出导航栏的位置和大小。 小程序提供了一个获取菜单按钮(右上角胶囊按钮)的布局位置信息的API,可以通过这个API获取到胶囊按钮的位置信息,但是经过实际测试,这个接口目前存在BUG,得到的值经常是错误的(通过特殊手段可以偶尔拿到正确的值),这个接口目前是无法使用的,等待官方修复吧。 下面是我经过实际测试得到的准确数据: 真机和开发者工具模拟器上的胶囊按钮不一样 [代码]# iOS top 4px right 7px width 87px height 32px # Android top 8px right 10px width 95px height 32px # 开发者工具模拟器(iOS) top 6px right 10px width 87px height 32px # 开发者工具模拟器(Android) top 8px right 10px width 87px height 32px [代码] [代码]top[代码] 的值是从 [代码]statusBarHeight[代码] 作为原点开始计算的。 使用上面数据中胶囊按钮的高度加 [代码]top[代码] * 2 上再加上 [代码]statusBarHeight[代码] 的高度就可以得到整个导航栏的高度了。 为什么 [代码]top[代码] * 2 ?因为胶囊按钮是垂直居中在 title 那一栏中的,上下都要有边距。 扩展 通过胶囊按钮的 [代码]right[代码] 可以准确的算出自定义导航的 [代码]左边距[代码]。 通过胶囊按钮的 [代码]right[代码] + [代码]width[代码] 可以准确的算出自定义导航的 [代码]右边距[代码] 。 通过 wx.getSystemInfo 或者 wx.getSystemInfoSync 中得到的 [代码]windowWidth[代码] - 胶囊按钮的 [代码]right[代码] + [代码]width[代码] 可以准确的算出自定义导航的 [代码]width[代码] 。 再扩展 wx.getSystemInfo 或者 wx.getSystemInfoSync 中得到的 [代码]statusBarHeight[代码] 每个机型都不一样,刘海屏得到的数据也是准确的。 如果是自定义整个页面,iPhone X系列的刘海屏,底部要留 [代码]68px[代码] ,不要问我为什么! 代码片段 https://developers.weixin.qq.com/s/Q79g6kmo7w5J
2019-02-25 - Wxml2Canvas -- 快速生成小程序分享图通用方案
Wxml2Canvas库,可以将指定的wxml节点直接转换成canvas元素,并且保存成分享图,极大地提升了绘制分享图的效率。目前被应用于微信游戏圈、王者荣耀、刺激战场助手等小程序中。 github地址:https://github.com/wg-front/wxml2canvas 一、背景 随着小程序应用的日渐成熟,多处场景需要能够生成分享图便于用户进行二次传播,从而提升小程序的传播率以及加强品牌效应。 对于简单的分享图,比如固定大小的背景图加几行简短文字构成的分享小图,我们可以利用官方提供的canvas接口将元素直接绘制, 虽然繁琐了些,但能满足基本要求。 对于复杂的分享图,比如用户在微信游戏圈发表完话题后,需要将图文混排的富文本内容生成分享图,对于这种长度不定,内容动态变化的图片生成需求,直接利用官方的canvas接口绘制是十分困难的,包括但不限于文字换行、表情文字图片混排、文字加粗、子标题等元素都需要一一绘制。又如王者荣耀助手小程序,需要将十人对局的详细战绩绘制成分享图,包含英雄数据、装备、技能、对局结果等信息,要绘制100多张图片和大量的文字信息,如果依旧使用官方的接口一步一步绘制,对开发者来说简直就是一场噩梦。我们急需一种通用、高效的方式完成上述的工作。 在这样的背景下,wxml2cavnas诞生了,作为一种分享图绘制的通用方案,它不仅能快速的绘制简单的固定小图,还能直接将wxml元素真实地转换成canvas元素,并且适配各种机型。无论是复杂的图文混排的富文本内容,还是展现形式多样的战绩结果页,都可以利用wxml2cavnas完美地快速绘制并生成所期望的分享图片。 二、Wxml2Canvas介绍及示例 1. 介绍 Wxml2Cavnas库,是一个生成小程序分享图的通用方案,提供了两种绘制方式: 封装基础图形的绘制接口,包括矩形、圆形、线条、图片、圆角图片、纯文本等,使用时只需要声明元素类型并提供关键数据即可,不需要再关注canvas的具体绘制过程; wxml直接转换成canvas元素,使用时传入待绘制的wxml节点的class类名,并且声明绘制此节点的类型(图片、文字等),会自动读取此节点的computedStyle,利用这些数据完成元素的绘制。 2. 生成图示例 下面是两张极端复杂的分享图。 2.1 游戏圈话题 [图片] 点击查看完整长图 2.2.2 王者荣耀战绩 [图片] 点击查看完整大图 三、小程序的特性及局限 小程序提供了如下特性,可供我们便捷使用: measureText接口能直接测量出文本的宽度; SelectorQuery可以查询到节点对应的computedStyle。 利用第一条,我们在绘制超长文本时便于文本的省略或者换行,从而避免文字溢出。 利用第二条,我们可以根据class类名,直接拿到节点的样式,然后将style转换成canvas可识别的内容。 但是和html的canvas相比,小程序的canvas局限性很多。主要体现在如下几点: 不支持base64图片; 图片必须下载到本地后才能绘制到画布上; 图片域名需要在管理平台加入downFile安全域名; canvas属于原生组件,在移动端会置于最顶层; 通过SelectorQuery只能拿到节点的style,而无法获取文本节点的内容以及图片节点的链接。 针对以上问题,我们需要将base64图片转换jpg或png格式的图片,实现图片的统一下载逻辑,并且离屏绘制内容。针对第五条,好在SelectorQuery可以获取到节点的dataset属性,所以我们需要在待绘制的节点上显示地声明其类型(imgae、text等),并且显示地传入文本内容或图片链接,后文会有示例。 四、Wxml2Canvas使用方式 1. 初始化 首先在wxml中创建canvas节点,指定宽高: [代码] <canvas canvas-id="share" style="height: {{ height * zoom }}px; width: {{ width * zoom }}px;"> </canvas> [代码] 引入代码库,创建DrawImage实例,并传入如下参数: [代码] let DrawImage = require('./wxml2canvas/index.js'); let zoom = this.device.windowWidth / 375; let width = 375; let height = width * 3; let drawImage = new DrawImage({ element: 'share', // canvas节点的id, obj: this, // 在组件中使用时,需要传入当前组件的this width: width, // 宽高 height: height, background: '#161C3A', // 默认背景色 gradientBackground: { // 默认的渐变背景色,与background互斥 color: ['#17326b', '#340821'], line: [0, 0, 0, height] }, progress (percent) { // 绘制进度 }, finish (url) { // 画完后返回url }, error (res) { console.log(res); // 画失败的原因 } }); [代码] 所有的数字参数均以iphone6为基准,其中参数width和height决定了canvas画布的大小,规定值是在iphone6机型下的固定数值; zoom参数的作用是控制画布的缩放比例,如果要求画布自适应,则应传入 windowWidth / 375,windowWidth为手机屏幕的宽度。 2. 传入数据,生成图片 执行绘制操作: [代码] drawImage.draw(data, this); [代码] 执行绘制时需要传入数据data,数据的格式分为两种,下面展开介绍。 2.1 基础图形 第一种为基础的图形、图文绘制,直接使用官方提供接口,下面代码是一个基本的格式: [代码] let data = { list: [{ type: 'image', url: 'https://xxx', class: 'background_image', // delay: true, x: 0, y: 0, style: { width: width, height: width } }, { type: 'text', text: '文字', class: 'title', x: 0, y: 0, style: { fontSize: 14, lineHeight: 20, color: '#353535', fontFamily: 'PingFangSC-Regular' } }] } [代码] 如上,type声明了要元素的类型,有image、text、rect、line、circle、redius_image(圆角图)等,能满足绝大多数情况。 class类名指定了使用的样式,需要在style中写出,符合css样式规范。 delay参数用来异步绘制元素,会把此元素放在第二个循环中绘制。 x,y用来指定元素的起始坐标。 将css样式与元素分离的目的是便于管理与复用。 此种方式每个元素都相互独立,互不影响,能够满足自由度要求高的情况,可控性高。 2.2 wxml转换 第二种方式为指定wxml元素,自动获取,下面是示例: [代码] let data = { list: [{ type: 'wxml', class: '.panel .draw_canvas', limit: '.panel' x: 0, y: 0 }] } [代码] 如上,type声明为wxml时,会查找所有类名为draw_canvas的节点,并且加入到绘制队列中。 class传入的第一个类名限定了查询的范围,可以不传,第二个用来指定查找的节点,可以定义为任意不影响样式展现的通用类名。 limit属性用来限定相对位置,例如,一个文本的位置(left, top) = (50, 80), class为panel的节点的位置为(left, top) = (20, 40),则文本canvas上实际绘制的位置(x, y) = (50 - 20, 80 -40) = (30, 40)。如果不传入limit,则以实际的位置(x, y) = (50, 80)绘制。 由于小程序节点元素查询接口的局限,无法直接获取节点的文本内容和图片标签的src属性,也无法直接区分是文本还是图片,但是可以获取到dataset,所以我们需要在节点上显示地声明data-type来指明类型,再声明data-text传入文字或data-url传入图片链接。下面是个示例: [代码] <view class="panel"> <view class="panel__img draw_canvas" data-type="image" data-url="https://xxx"></view> <view class="panel__text draw_canvas" data-type="text" data-text="文字">文字</view> </view> [代码] 如上,会查询到两个节点符合条件,第一个为image图片,第二个为text文本,利用SelectorQuery查询它们的computedStyle,分别得到left、top、width、height等数据后,转换成canvas支持的格式,完成绘制。 除此之外,下面的示例功能更加丰富: [代码] <view class="panel"> <view class="panel__text draw_canvas" data-type="background-image" data-radius="1" data-shadow="" data-border="2px solid #000"></view> <view class="panel__text draw_canvas" data-type="text" data-background="#ffffff" data-padding="2 3 0 0" data-delay="1" data-left="10" data-top="10" data-maxlength="4" data-text="这是个文字">这是个文字</view> </view> [代码] 如上,第一个data-type为background-image,表示读取此节点的背景图片,因为可以通过computedStyle直接获取图片链接,所以不需要显示传入url。声明data-radius属性,表示要将此图绘成乘圆形图片。data-border属性表示要绘制图片的边框,虽然也可以通过computedStyle直接获取,但是为了避免非预期的结果,还是要声明传入,border格式应符合css标准。此外,图片的box-shadow等样式都会根据声明绘制出来。 第二个文本节点,声明了data-background,则会根据节点的位置属性给文字增加背景。 data-padding属性用来修正背景的位置和宽高。data-delay属性用来延迟绘制,可以根据值的大小,来控制元素的层级,data-left和data-top用来修正位置,支持负值。data-maxlength用来限制文本的最大长度,超长时会截取并追加’…’。 此外,data-type还有inline-text,inline-image等行内元素的绘制,其实现较为复杂,会在后文介绍。 五、Wxml2Canvas实现原理 1. 绘制流程 整个绘制流程如下: [图片] 因为小程序的限制,只能在画布上绘制本地图片,所以统一先对图片提前下载,然后再绘制,为了避免图片重复下载,内部维护一个图片列表,会对相同的图片链接去重,减少等待时间。 2. 基本图形的实现 基础图形的绘制比较简单,内部实现只是对基础能力的封装,使用者不用再关注canvas的绘制过程,只需要提供关键数据即可,下面是一个图片绘制的实现示例: [代码] function drawImage (item, style) { if(item.delay) { this.asyncList.push({item, style}); }else { if(item.y < 0) { item.y = this.height + item.y * zoom - style.height * zoom; }else { item.y = item.y * zoom; } if(item.x < 0) { item.x = this.width + item.x * zoom - style.width * zoom; }else { item.x = item.x * zoom; } ctx.drawImage(item.url, item.x, item.y, style.width * zoom, style.height * zoom); ctx.draw(true); } } [代码] 如上,x,y值坐标支持传入负值,表示从画布的底部和右侧计算位置。 3. Wxml转Canvas元素的实现 3.1 computedStyle的获取 首先需要获取wxml的样式,代码示例如下: [代码] query.selectAll(`${item.class}`).fields({ dataset: true, size: true, rect: true, computedStyle: ['width', 'height', ...] }, (res) => { self.drawWxml(res); }) [代码] 3.2 块级元素的绘制 对于声明为image、text的元素,默认为块级元素,它们的绘制都是独立进行的,不需要考虑其他的元素的影响,以wxml节点为圆形的image为例,下面是部分代码: [代码] if(sub.dataset.type === 'image') { let r = sub.width / 2; let x = sub.left + item.x * zoom; let y = sub.top + item.y * zoom; let leftFix = +sub.dataset.left || 0; let topFix = +sub.dataset.top || 0; let borderWidth = sub.borderWidth || 0; let borderColor = sub.borderColor; // 如果是圆形图片 if(sub.dataset.radius) { // 绘制圆形的border if(borderWidth) { ctx.beginPath() ctx.arc(x + r, y + r, r + borderWidth, 0, 2 * Math.PI) ctx.setStrokeStyle(borderColor) ctx.setLineWidth(borderWidth) ctx.stroke() ctx.closePath() } // 绘制圆形图片的阴影 if(sub.boxShadow !== 'none') { ctx.beginPath() ctx.arc(x + r, y + r, r + borderWidth, 0, 2 * Math.PI) ctx.setFillStyle(borderColor); setBoxShadow(sub.boxShadow); ctx.fill() ctx.closePath() } // 最后绘制圆形图片 ctx.save(); ctx.beginPath(); ctx.arc((x + r), (y + r) - limitTop, r, 0, 2 * Math.PI); ctx.clip(); ctx.drawImage(url, x + leftFix * zoom, y + topFix * zoom, sub.width, sub.height); ctx.closePath(); ctx.restore(); }else { // 常规图片 } } [代码] 如上,块级元素的绘制和基础图形的绘制差异不大,理解起来也很容易,不再多述。 3.3 令人头疼的行内元素的绘制 当wxml的data-type声明为inline-image或者inline-text时,我们认为是行内元素。行内元素的绘制是一个难点,因为元素之前存在关联,所以不得不考虑各种临界情况。下面展开细述。 3.3.1 纯文本换行 对于长度超过一行的行内元素,需要计算出合适的换行位置,下图所示的是两种临界情况: [图片] [图片] 如上图所示,第一种情况为最后一行只有一个文字,第二种情况最后一行的文字长度和宽度相同。虽然长度不同,但都可通过下面代码绘制: [代码] let lineNum = Math.ceil(measureWidth(text) / maxWidth); // 文字行数 let sinleLineLength = Math.floor(text.length / lineNume); // 向下取整,保证多于实际每行字数 let currentIndex = 0; // 记录文字的索引位置 for(let i = 0; i < lineNum; i++) { let offset = 0; // singleLineLength并不是精确的每行文字数,要校正 let endIndex = currentIndex + sinleLineLength + offset; let single = text.substring(currentIndex, endIndex); // 截取本行文字 let singleWidth = measureWidth(single); // 超长时,左移一位,直至正好 while(singleWidth > maxWidth) { offset--; endIndex = currentIndex + sinleLineLength + offset; single = text.substring(currentIndex, endIndex); singleWidth = measureWidth(single); } currentIndex = endIndex; ctx.fillText(single, item.x, item.y + i * style.lineHeight); } // 绘制剩余的 if(currentIndex < text.length) { let last = text.substring(currentIndex, text.length); ctx.fillText(last, item.x, item.y + lineNum * style.lineHeight); } [代码] 为了避免计算太多次,首先算出大致的行数,求出每行的文字数,然后移位索引下标,求出实际的每行的字数,再下移一行继续绘制,直到结束。 3.3.2 非换行的图文混排 [图片] 上图是一个包含表情图片和加粗文字的混排内容,当使用Wxml2Canvas查询元素时,会将第一行的内容分为五部分: 文本内容:这是段文字; 表情图片:发呆表情(非系统表情,image节点展现); 表情图片:发呆表情; 文本内容:这也; 加粗文本内容:是一段文字,这也是文字。 对于这种情况,执行查询computedStyle后,会返回相同的top值。我们把top值相同的元素聚合在一起,认为它们是同一行内容,事实也是如此。因为表情大小的差异以及其他影响,默认规定top值在±2的范围内都是同一行内容。然后将top值的聚合结果按照left的大小从左往右排列,再一一绘制,即可完美还原此种情况。 3.3.3 换行的图文混排 当混排内容出现了换行情况时,如下图所示: [图片] 此时的加粗内容占据了两行,当我们依旧根据top值归类时,却发现加粗文字的left值取的是第二行的left值。这就导致加粗文字和第一部分的文字的top值和left值相同,如果直接绘制,两部分会发生重叠。 为了避免这种尴尬的情况,我们可以利用加粗文字的height值与第一部分文字的height值比较,显然前者是后者的两倍,可以得知加粗部分出现了换行情况,直接将其放在同组top列表的最后位置。换行的部分根据lineHeight下移绘制,同时做记录。 最后一部分的文本内容也出现了换行情况,同样无法得到真正的起始left值,并且其top值与上一部分换行后的top值相同。此时应该将他的left值追加加粗换行部分的宽度,正好得到真正的left值,最后再绘制。 大多数的行内元素的展现形式都能以上述的逻辑完美还原。 六、总结 基于基础图形封装和wxml转换这两种绘制方式,可以满足绝大多数的场景,能够极大地减少工作量,而不需要再关注内部实现。在实际使用中,二者并非孤立存在,而更多的是一起使用。 [图片] 如上图所示,对于列表内容我们利用wxml读取绘制,对于下部的白色区域,不是wxml节点内容,我们可以使用基础图形绘制方式实现。二者的结合更加灵活高效。 目前Wxml2Canvas已经在公司内部开源,不久会放到github上,同时也在不断完善中,旨在实现更多的样式展现与提升稳定性和绘制速度。 如果有更好的建议与想法,请联系我。
2019-02-28 - 你只需要一个小程序框架就够 - omix
omix 极小却精巧的小程序框架,对小程序入侵性几乎为零,强制中心化或者强制去中心化都是耍流氓 → Github 地址 5分钟精通 omi-mp-create API 去中心化 API [代码]create.Page(option)[代码] 创建页面 [代码]create.Component(option)[代码] 创建组件 [代码]this.oData[代码] 操作页面或组件的数据(会自动更新视图) [代码]this.store[代码] 页面注入的 store,页面和页面所有组件可以拿到 [代码]create.mitt()[代码] 事件发送和监听器 中心化 API [代码]create(store, option)[代码] 创建页面 [代码]create(option)[代码] 创建组件 [代码]this.store.data[代码] 全局 store 和 data,页面和页面所有组件可以拿到, 操作 data 会自动更新视图 [代码]create.emitter[代码] 事件发送和监听器,不同于 mitt() 每次会创建新的实例,emitter 是全局唯一,可以用于跨页面通讯 实战去中心化 API 页面 [代码]import create from '../../utils/create' const app = getApp() create.Page({ store: { abc: '公共数据从页面注入到页面的所有组件' //事件发送和监听器,或者 create.mitt() emitter: create.emitter }, data: { motto: 'Hello World', userInfo: { }, hasUserInfo: false, canIUse: wx.canIUse('button.open-type.getUserInfo') }, ... ... onLoad: function () { ... ... ... //监听事件 this.store.emitter.on('foo', e => console.log('foo', e) ) setTimeout(() => { this.oData.userInfo = { nickName: 'dnt', avatarUrl: this.data.userInfo.avatarUrl } }, 2000) setTimeout(() => { this.oData.userInfo.nickName = 'dntzhang' }, 4000) } }) [代码] 这里需要注意,oData 必须直接操作 data 里定义好的数据才能直接更新视图,比如 [代码]nickName[代码] 一开始没有定义好,更新它是不会更新视图,只有通过下面代码执行之后,才能更新 nickName,因为 userInfo 的变更会自动监听 userInfo 内的所有属性: [代码]this.oData.userInfo = { nickName: 'dnt', avatarUrl: this.data.userInfo.avatarUrl } [代码] 当然你也可以直接在 data 里声明好: [代码] data: { motto: 'Hello World', userInfo: { nickName: null }, ... [代码] 组件 [代码]import create from '../../utils/create' create.Component({ data: { a: { b: Math.random() } }, ready: function () { //这里可以或者组件所属页面注入的 store console.log(this.store) //触发事件 this.store.emitter.emit('foo', { a: 'b' }) setTimeout(()=>{ this.oData.a.b = 1 },3000) }, }) [代码] 数组 [代码]import create from '../../utils/create' import util from '../../utils/util' create.Page({ data: { logs: [] }, onLoad: function () { this.oData.logs = (wx.getStorageSync('logs') || []).map(log => { return util.formatTime(new Date(log)) }) setTimeout(() => { this.oData.logs[0] = 'Changed!' }, 1000) } }) [代码] 这里需要注意,改变数组的 length 不会触发视图更新,需要使用 size 方法: [代码]this.oData.yourArray.size(3) [代码] 其他 [代码]this.oData.arr.push(111) //会触发视图更新 //每个数组的方法都有对应的 pureXXX 方法 this.oData.arr.purePush(111) //不会触发视图更新 this.oData.arr.size(2) //会触发视图更新 this.oData.arr.length = 2 //不会触发视图更新 [代码] mitt 语法 [代码]const emitter = mitt() // listen to an event emitter.on('foo', e => console.log('foo', e) ) // listen to all events emitter.on('*', (type, e) => console.log(type, e) ) // fire an event emitter.emit('foo', { a: 'b' }) // working with handler references: function onFoo() {} emitter.on('foo', onFoo) // listen emitter.off('foo', onFoo) // unlisten [代码] 详细参见 mitt github omi-mp-create 为 mitt 新增的语法: [代码]emitter.off('foo') // unlisten all foo callback [代码] 实战中心化 API 定义 store: [代码]export default { data: { logs: [] } } [代码] 定义页面: [代码]import create from '../../utils/create' import util from '../../utils/util' import store from '../../store' create(store, { onLoad: function () { this.store.data.logs = (wx.getStorageSync('logs') || []).map(log => { return util.formatTime(new Date(log)) }) setTimeout(() => { this.store.data.logs[0] = 'Changed!' }, 1000) setTimeout(() => { this.store.data.logs.push(Math.random(), Math.random()) }, 2000) setTimeout(() => { this.store.data.logs.splice(this.store.data.logs.length - 1, 1) }, 3000) } }) [代码] [代码]<view class="container log-list"> <block wx:for="{{logs}}" wx:for-item="log"> <text class="log-item">{{index + 1}}. {{log}}</text> </block> <view> <test-store></test-store> </view> </view> [代码] 可以看到里面使用 test-store 组件, 看下组件源码: [代码]import create from '../../utils/create' create({ }) [代码] [代码]<view class="ctn"> <view> <text>Log Length: {{logs.length}}</text> </view> </view> [代码] 喜欢中心化还是喜欢去中心化任你挑选,或者同一个小程序可以混合两种模式。 <!– 原理 最开始 [代码]omi-mp-create[代码] 打算使用 proxy,但是调研了下兼容性,还是打算使用 obaa 来进行数据变更监听。 因为小程序 IOS 使用内置的 jscore,安卓使用 x5,所以 Proxy 兼容性(IOS10+支持,安卓基本都支持) [图片] 实时统计地址:https://developer.apple.com/support/app-store/ [代码]this.setData({ logs: [1, 2, 3] }) setTimeout(() => { this.setData({ 'logs[2]': null }) }, 2000) setTimeout(() => { console.log(this.data.logs.length) }, 3000) [代码] 页面生命周期函数 名称 描述 onLoad 监听页面加载 onShow 监听页面显示 onReady 监听页面初次渲染完成 onHide 监听页面隐藏 onUnload 监听页面卸载 组件生命周期函数 名称 描述 created 在组件实例进入页面节点树时执行,注意此时不能调用 setData attached 在组件实例进入页面节点树时执行 ready 在组件布局完成后执行,此时可以获取节点信息(使用 SelectorQuery ) moved 在组件实例被移动到节点树另一个位置时执行 detached 在组件实例被从页面节点树移除时执行 开始使用吧! MIT Lic
2019-04-09 - 微信小程序前端生成图片用于分享朋友圈最终解决方案
前段时间一直在做微信小程序的,遇到了许多的坑,其中遇到了需要前端合成图片保存到相册用于分享到朋友圈。借简书记录一下最终解决方案,先看一下最终效果 [图片] 该文章的所有演示代码托管与github,代码地址,微信调试工具中访问请[代码]关闭合法域名检查[代码],[代码]开启es6转换[代码],真机调试请打开调试[代码]vconsole[代码] 该文章解决的问题如下: 微信小程序生成图片,并保存到相册 微信小程序生成图片实现响应式 微信小程序canvas原生组件如何给画布添加css动画 保存高清分享图方案 微信小程序生成图片实现单屏适应 微信小程序生成图片,并保存到相册 首先,我们希望能实现如下功能,点击用户头像,从底部弹出一个分享弹窗,可以保存合成图片到相册,可以关闭弹层 我们将该功能封装成一个Component自定义组件 定义wxml基本结构 [代码]<view class="share {{visible ? 'show' : ''}}"> <view class="content"> <canvas class="canvas" canvas-id="share" /> <view class="footer"> <view class="save">保存到相册</view> <view class="close">关闭</view> </view> </view> </view> [代码] 定义wxss样式 [代码].share { position: fixed; top: 0; left: 0; min-height: 100vh; width: 100%; background: rgba(61, 61, 61, 0.5); visibility: hidden; opacity: 0; transition: opacity 0.2s ease-in-out; z-index: 99999; } .share.show { visibility: visible; opacity: 1; } .share .content { display: flex; flex-direction: column; justify-content: center; align-items: center; } .share .content .footer { width: 562rpx; height: 100rpx; background: #fff; border-top: 2rpx solid #e9e9e9; display: flex; flex-direction: row; justify-content: center; align-items: center; font-size: 28rpx; } .share .content .footer .close { width: 100rpx; height: 100rpx; line-height: 100rpx; flex-grow: 0; flex-shrink: 0; text-align: center; border-left: 2rpx solid #e9e9e9; } .share .content .footer .save { height: 100rpx; line-height: 100rpx; flex-grow: 1; flex-shrink: 1; text-align: center; } .share.show .content .canvas { display: inline-block; } .share .content .canvas { display: inline-block; background: #fff; margin: 60rpx 0 0 0; width: 562rpx; height: 1000rpx; } [代码] 定义json [代码]{ "component": true } [代码] 定义组件构造器 [代码]Component({ properties: { visible: { type: Boolean, value: false }, // 由于需要绘制用户信息,由页面传入 userInfo: { type: Object, value: false } }, methods: { draw() { // 实际绘制函数,后续绘制代码放于此处 } } }) [代码] 基本结构和样式定义完成,接下来开始可一开始我们绘制之旅了,合成图片需要用到微信小程序wx.getImageInfo函数,我们先对它进行Promise化方便后期调用 [代码]function getImageInfo(url) { return new Promise((resolve, reject) => { wx.getImageInfo({ src: url, success: resolve, fail: reject, }) }) } [代码] 前期的准备工作建立完成,我们开始定义绘制方法draw [代码]const { userInfo } = this.data const { avatarUrl, nickName } = userInfo // 获取头像图像信息 const avatarPromise = getImageInfo(avatarUrl) // 获取背景图像信息 const backgroundPromise = getImageInfo('https://img.xiaomeipingou.com/_assets_home-share-bg.jpg') Promise.all([avatarPromise, backgroundPromise]) .then(([avatar, background]) => { // 创建绘图上下文 const ctx = wx.createCanvasContext('share', this) const canvasWidth = 281 const canvasHeight = 500 // 绘制背景,填充满整个canvas画布 ctx.drawImage(background.path, 0, 0, canvasWidth, canvasHeight) const avatarWidth = 60 const avatarHeight = 60 const avatarTop = 40 // 绘制头像 ctx.drawImage( avatar.path, canvasWidth / 2 - avatarWidth / 2, avatarTop - avatarHeight / 2, avatarWidth, avatarHeight ) // 绘制用户名 ctx.setFontSize(20) ctx.setTextAlign('center') ctx.setFillStyle('#ffffff') ctx.fillText( nickName, canvasWidth / 2, avatarTop + 50, ) ctx.stroke() // 完成作画 ctx.draw() }) [代码] 接下来,我们需要监测visible属性的变化,决定是否开始绘制 [代码]Component({ properties: { visible: { type: Boolean, value: false, observer(visible) { // 当开始显示分享弹窗时开始绘制 if (visible) { this.draw() } } }, }, ....省略其他代码 }) [代码] 此时,前端的绘制已基本成型,运行小程序变可看见合成图,由于我们的绘制尺寸是基于iphone6s进行绘制的,在iphone6s及部分相同分辨率查看,尺寸完全吻合,没有任何问题,然而当我们用iphone6s plus或者其他不同分辨率的手机打开时却变成了下面这个样子 [图片] 绘制的图像没有完全占满画布了,为什么呢?这个是遇到的第二个问题 微信小程序生成图片实现响应式 其实我们的画布宽高单位都是基于rpx单位,因此在不同分辨率的手机上,实际的尺寸也就不同,然而我们绘制图片的尺寸都是以px为单位,自然无法实现响应式,因此我们需要一个js方法用于转换rpx值为px值 解读微信官方文档我们定义如下一个简单的转换方法 [代码]function createRpx2px() { const { windowWidth } = wx.getSystemInfoSync() return function(rpx) { return windowWidth / 750 * rpx } } const rpx2px = createRpx2px() [代码] 定义好了单位转换函数,我们只需转换相关值即可 [代码]const { userInfo } = this.data const { avatarUrl, nickName } = userInfo // 获取头像图像信息 const avatarPromise = getImageInfo(avatarUrl) // 获取背景图像信息 const backgroundPromise = getImageInfo('https://img.xiaomeipingou.com/_assets_home-share-bg.jpg') Promise.all([avatarPromise, backgroundPromise]) .then(([avatar, background]) => { // 创建绘图上下文 const ctx = wx.createCanvasContext('share', this) const canvasWidth = rpx2px(281 * 2) const canvasHeight = rpx2px(500 * 2) // 绘制背景,填充满整个canvas画布 ctx.drawImage(background.path, 0, 0, canvasWidth, canvasHeight) const avatarWidth = rpx2px(60 * 2) const avatarHeight = rpx2px(60 * 2) const avatarTop = rpx2px(40 * 2) // 绘制头像 ctx.drawImage( avatar.path, canvasWidth / 2 - avatarWidth / 2, avatarTop - avatarHeight / 2, avatarWidth, avatarHeight ) // 绘制用户名 ctx.setFontSize(rpx2px(20 * 2)) ctx.setTextAlign('center') ctx.setFillStyle('#ffffff') ctx.fillText( nickName, canvasWidth / 2, avatarTop + rpx2px(50 * 2), ) ctx.stroke() // 完成作画 ctx.draw() [代码] 此时不管在什么分辨率下的手机都能正常显示了 微信小程序canvas原生组件如何给画布添加css动画 我们都知道微信小程序的canvas是原生组件,对于原生组件有许多的限制,比如不可以使用css动画,官方文档如下: [图片] 首先我们试着给canvas父层标签View.content标签添加弹出动画,修改样式如下: [代码].share .content { display: flex; flex-direction: column; justify-content: center; align-items: center; // 新增动画控制 transform: translate3d(0, 100%, 0); transition: transform 0.2s ease-in-out; } // 新增动画控制 .share.show .content { transform: translate3d(0, 0, 0); } [代码] 在调试器中使用,一切都很美好,完全按着预期由底部弹出,然后淡隐,不过当你用真机调试,canvas部分的效果变得不那么顺畅,流畅,没有弹出动画,没有淡隐效果,一切都变得那么的僵硬,那我们该怎么办呢? 解决办法的思路如下: 提供一个canvas标签,不可以做隐藏(隐藏会导致绘制失效),通过css tansform属性移除屏幕让其不可见 用image标签代替canvas标签显示给用户查看 当画布绘制完成后,我们保存绘制的图像到临时目录中,并获取图片地址 将地址提供给image标签用于展示 基于以上思路,首先改造我们的文档结构 [代码]<view class="share {{ visible ? 'show' : '' }}"> <canvas class="canvas-hide" canvas-id="share" /> <view class="content"> <image class="canvas" src="{{imageFile}}" /> <view class="footer"> <view class="save">保存到相册</view> <view class="close" bindtap="handleClose">关闭</view> </view> </view> </view> [代码] 新增样式 [代码].share .canvas-hide { position: fixed; top: 0; left: 0; transform: translateX(-100%); width: 562rpx; height: 1000rpx; } [代码] 想要保存canvas绘制的图像到临时目录,我们需要利用微信小程序的一个api接口wx.canvasToTempFilePath,因此首先我们还是对其进行Promise化 [代码]function canvasToTempFilePath(option, context) { return new Promise((resolve, reject) => { wx.canvasToTempFilePath({ ...option, success: resolve, fail: reject, }, context) }) } [代码] 在组件的data属性中新增imageFile [代码]// 仅列出新增部分,省略之前的代码 Component({ data: { imageFile: '' } }) [代码] 修改我们的绘制方法 [代码]// 仅列出新增部分,省略之前的代码 // 修改画布的draw函数如下 ctx.draw(false, () => { canvasToTempFilePath({ canvasId: 'share', }, this).then(({ tempFilePath }) => this.setData({ imageFile: tempFilePath })) }) [代码] 此时在真机上运行调试,可以看到完美的满足我们的需求(沾沾自喜) 保存高清分享图方案 接下来我们需要实现保存到相册中,用于分享给朋友圈或者其他微博 保存图片到相册需要调用微信小程序api,wx.saveImageToPhotosAlbum,依照惯例进行Promise化 [代码]function saveImageToPhotosAlbum(option) { return new Promise((resolve, reject) => { wx.saveImageToPhotosAlbum({ ...option, success: resolve, fail: reject, }) }) } [代码] 我们为保存相册新增点击事件 [代码]<view class="save" bindtap="handleSave">保存到相册</view> [代码] 最后定义我们的保存方法 [代码]// 仅列出新增部分,省略之前的代码 Component({ methods: { handleSave() { const { imageFile } = this.data if (imageFile) { saveImageToPhotosAlbum({ filePath: imageFile, }).then(() => { wx.showToast({ icon: 'none', title: '分享图片已保存至相册', duration: 2000, }) }) } } } }) [代码] 至此保存到相册功能完成了,但是有点瑕疵,原本我们用于绘制的图片非常的高清,可以绘制后保存的图片变得模糊了,没那么高清,这是过不了UED小姐姐那关的 那如何保证保存的图片不会失真呢,我们可以考虑把canvas大小放大到3倍,绘制3倍的图 修改样式 [代码].share .content .canvas { display: inline-block; background: #fff; margin: 60rpx 0 0 0; width: 1686rpx; // 修改为之前的3倍 height: 3000rpx; // 修改为之前的3倍 } [代码] 修改绘制函数,增长绘制大小为3倍 [代码]const { userInfo } = this.data const { avatarUrl, nickName } = userInfo // 获取头像图像信息 const avatarPromise = getImageInfo(avatarUrl) // 获取背景图像信息 const backgroundPromise = getImageInfo('https://img.xiaomeipingou.com/_assets_home-share-bg.jpg') Promise.all([avatarPromise, backgroundPromise]) .then(([avatar, background]) => { // 创建绘图上下文 const ctx = wx.createCanvasContext('share', this) const canvasWidth = rpx2px(281 * 2 * 3) // 扩大3倍 const canvasHeight = rpx2px(500 * 2 * 3) // 扩大3倍 // 绘制背景,填充满整个canvas画布 ctx.drawImage(background.path, 0, 0, canvasWidth, canvasHeight) const avatarWidth = rpx2px(60 * 2 * 3) // 扩大3倍 const avatarHeight = rpx2px(60 * 2 * 3) // 扩大3倍 const avatarTop = rpx2px(40 * 2 * 3) // 扩大3倍 // 绘制头像 ctx.drawImage( avatar.path, canvasWidth / 2 - avatarWidth / 2, avatarTop - avatarHeight / 2, avatarWidth, avatarHeight ) // 绘制用户名 ctx.setFontSize(rpx2px(20 * 2 * 3)) // 扩大3倍 ctx.setTextAlign('center') ctx.setFillStyle('#ffffff') ctx.fillText( nickName, canvasWidth / 2, avatarTop + rpx2px(50 * 2 * 3), // 扩大3倍 ) ctx.stroke() // 完成作画 ctx.draw(false, () => { canvasToTempFilePath({ canvasId: 'share', }, this).then(({ tempFilePath }) => this.setData({ imageFile: tempFilePath })) }) [代码] 我们重新保存图片,发现图片变得高清了,hu~~~ 最后我们可以兴高采烈的把成果交给小测试了,一切看起来都很顺利,可惜终究过不了各种机型分辨率的测试,由于我们的设计基于iphone6s尺寸设计,在部分宽高比不同的机型,高度会超出屏幕高度,变成下面这个样子 [图片] 按钮被挡住了,这下无奈了 微信小程序生成图片实现单屏适应 我们希望分享弹窗内容能在一个屏幕下显示完全,那可以根据当前手机宽高比与设计稿尺寸宽高比求出一个缩放比例对整体内容进行缩放即可 定义缩放比例计算 [代码]// 仅列出新增部分,省略之前的代码 Component({ data: { responsiveScale: 1, // 缩放比例默认为1 }, lifetimes: { ready() { const designWidth = 375 const designHeight = 603 // 这是在顶部位置定义,底部无tabbar情况下的设计稿高度 // 以iphone6为设计稿,计算相应的缩放比例 const { windowWidth, windowHeight } = wx.getSystemInfoSync() const responsiveScale = windowHeight / ((windowWidth / designWidth) * designHeight) if (responsiveScale < 1) { this.setData({ responsiveScale, }) } }, }, }) [代码] 修改wxml文档 [代码]<view class="share {{ visible ? 'show' : '' }}"> <canvas class="canvas-hide" canvas-id="share" /> <view class="content" style="transform:scale({{responsiveScale}});-webkit-transform:scale({{responsiveScale}});"> <image class="canvas" src="{{imageFile}}" /> <view class="footer"> <view class="save" bindtap="handleSave">保存到相册</view> <view class="close" bindtap="handleClose">关闭</view> </view> </view> </view> [代码] 修改wxss样式表 [代码].share .content { // 省略其他定义 // 新增缩放中心控制为顶部中心 transform-origin: 50% 0; } [代码] 整体分享遇到的坑都得到了解决,代码较多,所有的代码都托管到了github,欢迎访问运行代码地址,只有亲力亲为才能真正的掌握知识
2019-01-14 - 微信服务号/小程序提醒消息机制简述
日常的微信H5营销活动/微信小程序中,经常会碰到需要定时通知/提醒用户的场景,本文主要帮你了解各种通知用户的方法和场景. 各类消息功能对比 先上各类消息功能和限制上的对比结果,如下表 [图片] 服务号也有客服消息,但触发条件比较苛刻,需要 1、用户发送信息 2、点击自定义菜单(仅有点击推事件、扫码推事件、扫码推事件且弹出“消息接收中”提示框这3种菜单类型是会触发客服接口的) 3、关注公众号 4、扫描二维码 5、支付成功 6、用户维权 跟平时H5跟小程序的场景不符合,本文暂不讨论服务号的客服消息 服务号发送模板消息流程 1.第一次使用模板消息需要申请模板: 登陆服务号后台,访问功能->模板消息,设置对应的行业,并申请模板,并获得模板id和模板格式 [图片] 2.调用发送接口 [图片] 用户必须关注才能收到服务号模板消息! 用户必须关注才能收到服务号模板消息! 用户必须关注才能收到服务号模板消息! 发送给未关注的用户会得到类似的返回结果: {“errcode”:43004,“errmsg”:“require subscribe hint: [l8Xf.a0132dsz1]”} 详细参数接口请看: https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1433751277 3.用户接收消息效果图 会话列表:[图片] 会话内容页: [图片] 发送一次性订阅消息流程 1.获取用户授权 控制页面跳转到https://mp.weixin.qq.com/mp/subscribemsg?action=get_confirm&appid=你的AppId&scene=1000&template_id=1uDxHNXwYQfBmXOfPJcjAS3FynHArD8aWMEFNRGSbCc&redirect_url=跳转回来的链接&reserved=test#wechat_redirect 参数说明: appid:服务号appid scene:场景值id,一般一个活动页面用一个唯一的即可,1-10000的int类型 reserved:防CSRF的参数,随机值就可以,回调地址再校验一次,可选参数 redirect_url:接收回调参数的地址,urlencode,第一次接入必须在设置-公众号设置-功能设置 添加业务域名,如下图 [图片] template_id:在公众号后台获取即可,接口权限->一次性订阅消息->查看模板id,如下图 [图片] [图片] 2.用户跳转到页面后会微信显示如下内容 [图片] 用户确认后,会跳转到上面传过去的redirect_url,会带上以下参数 openid:用户id,用户拒绝没有此参数 template_id:模板id action:用户确认则是confirm,反之为cancel scene:场景值 reserved:防csrf攻击的参数 后端保存openid,template和scene,用于发送数据 3.后端调用接口发送消息 到了需要发送的时候(活动到点提醒/抽奖结果通知等等),后端再调用接口发送: [图片] 可发送小程序或网页url,网页的url无域名限制,用户无需关注也可以发送消息,订阅号也可以发送此类消息. 更多参数以官方文档为准: https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1500374289_66bvB 3.发送效果图 已关注服务号的用户会出现在服务号会话里,如下: [图片] 未关注服务号的用户会出现在服务通知: [图片] 小程序发送客服消息流程 由于前期开发者对客服消息的滥用,微信封禁了用户进入会话的事件,现在需要用户从客服会话窗口主动发送消息,服务端收到事件才可以对用户推送消息. 自4月9日起,用户通过客服消息按钮进入会话,该动作不再支持开发者给用户下发客服消息,改由平台统一给用户展示接入提示。开发者仅可在用户主动咨询后进行回复,否则将收到45047的错误码返回:客服接口下行条数超过上限。请尽快进行适配。 1.设置消息推送URL 如果是第一次接入客服消息,需要设置消息推送url 设置->开发设置->消息推送 [图片] 接入的详细文档请看:https://developers.weixin.qq.com/miniprogram/dev/api/custommsg/callback_help.html 2.引导用户发送消息到客服消息 引导用户点击’open-type=“contact”'的button进入客服会话聊天窗口,并让用户发送对应文字 <button class="download" open-type="contact" session-from="wesixpuzzleapp"></button> 如下图,点击后即进入客服会话 [图片] 服务端接收微信的消息推送后,可以下发消息到聊天窗,该消息可以打开任意url和小程序,可拉起应用下载 [图片] 小程序发送模板消息流程 1.一如既往地先申请/添加模板 [图片] 2.引导用户提交form 一般是让用户点击form里面的button,如签到/留言等功能按钮,form标签的report-submit属性必须设置为true,如: <form bindsubmit="formSubmit" report-submit="{{true}}"> <button class="download" formType="submit"></button> </form> (样式也是继续使用上面客服消息的例子) [图片] 3.向后台传递formId 用户点击后,触发bindsubmit的事件,callback的参数会带上formId,传递这个formId到后台 formSubmit: function(e) { wx.request({ url: ‘https://xxxx.xxx/miniapp/push-message’, method: “post”, data: { session_id: app.globalData.sessionId, form_id: e.detail.formId, //formId,重要 }, success(e) { console.log(e) }, }) }, [代码]4.后台发送模板消息 [代码] 注意,formId七天内有效,超过其他就不能再用该formId向用户发送模板消息,该消息只能打开原有的小程序 [图片] 后台的api文档请看这里https://developers.weixin.qq.com/miniprogram/dev/api/notice.html#%E5%8F%91%E9%80%81%E6%A8%A1%E6%9D%BF%E6%B6%88%E6%81%AF 5.用户将在服务通知收到推送的模板消息 效果如下: [图片]
2019-01-28 - getCurrentPages()的用法
getCurrentPages()的用法 getCurrentPages()是个好东西,今天来说说他的用法。 先看看官方文档: [路由 · 小程序]:https://developers.weixin.qq.com/miniprogram/dev/framework/app-service/route.html [图片] getCurrentPages() 函数用于获取当前页面栈的实例,以数组形式按栈的顺序给出,第一个元素为首页,最后一个元素为当前页面。 简单说,就是可以获取到当前小程序的页面栈 那么,获取到页面栈,有什么用处呢? 1、判断页面栈是否超过10级,超过10级,将不能打开新页面(主要是不用用navigateTo方式打开)。 2、可以修改某个页面栈的data数据,或者方法。 这里给大家分享一个实际应用场景:仅发起者可分享。 有些投票、通知、抽奖、签到等,发起者会在私密的圈子内进行,比如,仅会员群才能参与的抽奖、公司内部的通知公告、班级内部的投票等。发起者是不希望别人分享出去,那么小程序里面要怎么做? 说到分享,在小程序内,应该是想到onShareAppMessage这个方法。只要page.js中有这个方法,不管你是否在内部写了代码逻辑,小程序默认就是可以分享的,如果没有这个方法,小程序右上角的“…”就不会出现“转发”的选项。 问题是,是否允许分享,一般都是小程序内的一个开关设置项,可以看下图: [图片] 用户在加载内容时,需要先从服务端获取到这个开关状态,再决定是否出现“转发”的选项。 此时,我们默认不给page添加onShareAppMessage方法,这样,你转发出去的小程序卡片,别人将无法通过长按进行分享(群聊无法长按分享,私聊还是个坑,看下图)。 [图片] [图片] 然后再动态设置当前page的onShareAppMessage方法,用this,或者getCurrentPages()都能解决,看下图: [图片] 目前私聊的卡片,长按依然可以转发,似乎不是很完美,但是,功能基本实现了。 如果想让私聊卡片的转发无效,你也可以变通一下,比如做个限群成员可见功能,即使私聊卡片被转发,可以判断小程序场景值,不展示内容即可~ 1、用wx.getLaunchOptionsSync() 获取小程序启动时的参数: [图片] 2、判断群聊和私聊的场景值: [图片] 3、如果是微信私聊中打开,给用户提示即可~ [图片] 欢迎各位一起讨论技术问题:mianhuabingbei
2019-02-20 - 复制任意微信小程序页面路径
以下以微信小程序“虎牙直播”为例,演示如何复制微信小程序页面的路径。 1.进入小程序的“关于虎牙直播”页面 [图片] 2.点击右上角的“…”进入“更多资料”页面 [图片] [图片] [图片] 3.复制AppID:wx74767bf0b684f7d3 4.进入小程序后台输入appid并搜索,然后点下一步 [图片] 5.鼠标移动到“获取更多页面路径”,在弹出窗口输入当前登陆的小程序的任意开发者微信号,然后点击开启,出现顶部的“开启入口成功”就可以使用手机访问“虎牙直播”任意页面进行复制了 [图片] 6.某个直播间的页面路径:pages/main/liveRoom/index.html?anchorUid=1678113423&source=search[图片] PS:复制出来的页面路径在小程序里使用的时候记得删除 .html 才能正常访问。
2020-01-16 - 【优化】解决swiper渲染很多图片时的卡顿
相信各位在开发的时候应该有遇到这样一个场景,比如商品的图片浏览,有时图片的浏览会很大,多的时候达几百张或上千张,这样就需要swiper里需要很多swiper-item,如此一来渲染的时候就会很消耗性能,渲染时会有一大段的空白时间,有时还会造成卡顿,体验非常差,下面给大家介绍一下我的解决方案。 首先是wxml结构: [图片] js: [图片] [图片] 主要是利用current属性,swiper里面只放3个swiper-item,要显示的图片放在第二,第一和第三放的是加载的动画背景,步骤如下: 将请求到的数据存入一个数组picListAll内,这里不需要setData,只需要在data外面定义一个变量就行了,以减少渲染性能; 把要显示的图片路径赋值给picUrl; 切换的时候根据bindchange获取current属性,当current改变时判断当前图片在picListAll的index,根据index拿到图片再赋值给picUrl; 主要实现步骤就是以上3 步,比较简单,要注意的是当切换到第一张和最后一张的时候要判断一下,把loding动画去掉,请求的时候还可以传入index参数以显示不同的图片,方便从前一页点击图片进入到此页面时能定位到该图片,例子里我是自己mock数据的,只是为了展示,如果你有服务器的话可以弄几百张看看效果,对比直接渲染和用以上方式渲染的差异。当然,这只是我的解决方案,如果各位有更好的方案欢迎一起讨论,一起进步。 完整代码:https://github.com/HaveYuan/swiper
2019-02-20 - 滚动条的隐藏
实现代码是 /* ::-webkit-scrollbar { width: 0; height: 0; color: transparent; display: none; } */ 但是今天使用的时候突然不能生效了,之前是可以的。 研究了一下,发现没有给父级元素设置属性。 .store-page { //父元素 width: 100vw; height: 100vh; overflow-x: hidden; overflow-y: auto; }
2019-02-21 - 背景图片设置
开发微信小程序时,不能直接在wxss文件里引用本地图片,运行时会报错:“本地资源图片无法通过WXSS获取,可以使用网络图片,或者 base64,或者使用<image/>标签。” 这里主要介绍使用<image>标签的方法 网上有很多方法,笔者也尝试了不少,期间也遇到一些问题。最后总结一下,只需2步: 1、在wxml文件中添加一个<image>标签: [代码]<!--页面根标签--> <view class="content"> <!--pics文件夹下的background.jpg文件--> <image class='background' src="../../pics/background.jpg" mode="aspectFill"></image> <!--页面其它部分--> </view> [代码] 2、在wxss文件中添加: [代码]page{ height:100%; } .background { width: 100%; height: 100%; position:fixed; background-size:100% 100%; z-index: -1; } [代码] 要说明的是z-index: -1,可以让图片置于最底层,不会影响其它部分。
2019-02-21 - 轻松生成小程序分享海报
小程序海报组件 https://github.com/jasondu/wxa-plugin-canvas 需求 小程序分享到朋友圈只能使用小程序码海报来实现,生成小程序码的方式有两种,一种是使用后端方式,一种是使用小程序自带的canvas生成;后端的方式开发难度大,由于生成图片耗用内存比较大对服务端也是不小的压力;所以使用小程序的canvas是一个不错的选择,但由于canvas水比较深,坑比较多,还有不同海报需要重现写渲染流程,导致代码冗余难以维护,加上不同设备版本的情况不一样,因此小程序海报生成组件的需求十分迫切。 在实际开发中,我发现海报中的元素无非一下几种,只要实现这几种,就可以通过一份配置文件生成各种各样的海报了。 海报中的元素分类 [图片] 要解决的问题 单位问题 canvas隐藏问题 圆角矩形、圆角图片 多段文字 超长文字和多行文字缩略问题 矩形包含文字 多个元素间的层级问题 图片尺寸和渲染尺寸不一致问题 canvas转图片 IOS 6.6.7 clip问题 关于获取canvas实例 单位问题 canvas绘制使用的是px单位,但不同设备的px是需要换算的,所以在组件中统一使用rpx单位,这里就涉及到单位怎么换算问题。 通过wx.getSystemInfoSync获取设备屏幕尺寸,从而得到比例,进而做转换,代码如下: [代码]const sysInfo = wx.getSystemInfoSync(); const screenWidth = sysInfo.screenWidth; this.factor = screenWidth / 750; // 获取比例 function toPx(rpx) { // rpx转px return rpx * this.factor; } function toRpx(px) { // px转rpx return px / this.factor; }, [代码] canvas隐藏问题 在绘制海报过程时,我们不想让用户看到canvas,所以我们必须把canvas隐藏起来,一开始想到的是使用display:none; 但这样在转化成图片时会空白,所以这个是行不通的,所以只能控制canvas的绝对定位,将其移出可视界面,代码如下: [代码].canvas.pro { position: absolute; bottom: 0; left: -9999rpx; } [代码] 圆角矩形、圆角图片 由于canvas没有提供现成的圆角api,所以我们只能手工画啦,实际上圆角矩形就是由4条线(黄色)和4个圆弧(红色)组成的,如下: [图片] 圆弧可以使用canvasContext.arcTo这个api实现,这个api的入参由两个控制点一个半径组成,对应上图的示例 [代码]canvasContext.arcTo(x1, y1, x2, y2, r) [代码] 接下来我们就可以非常轻松的写出生成圆角矩形的函数啦 [代码]/** * 画圆角矩形 */ _drawRadiusRect(x, y, w, h, r) { const br = r / 2; this.ctx.beginPath(); this.ctx.moveTo(this.toPx(x + br), this.toPx(y)); // 移动到左上角的点 this.ctx.lineTo(this.toPx(x + w - br), this.toPx(y)); // 画上边的线 this.ctx.arcTo(this.toPx(x + w), this.toPx(y), this.toPx(x + w), this.toPx(y + br), this.toPx(br)); // 画右上角的弧 this.ctx.lineTo(this.toPx(x + w), this.toPx(y + h - br)); // 画右边的线 this.ctx.arcTo(this.toPx(x + w), this.toPx(y + h), this.toPx(x + w - br), this.toPx(y + h), this.toPx(br)); // 画右下角的弧 this.ctx.lineTo(this.toPx(x + br), this.toPx(y + h)); // 画下边的线 this.ctx.arcTo(this.toPx(x), this.toPx(y + h), this.toPx(x), this.toPx(y + h - br), this.toPx(br)); // 画左下角的弧 this.ctx.lineTo(this.toPx(x), this.toPx(y + br)); // 画左边的线 this.ctx.arcTo(this.toPx(x), this.toPx(y), this.toPx(x + br), this.toPx(y), this.toPx(br)); // 画左上角的弧 } [代码] 如果是画线框就使用[代码]this.ctx.stroke();[代码] 如果是画色块就使用[代码]this.ctx.fill();[代码] 如果是圆角图片就使用 [代码]this.ctx.clip(); this.ctx.drawImage(***); [代码] clip() 方法从原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内(不能访问画布上的其他区域)。可以在使用 clip() 方法前通过使用 save() 方法对当前画布区域进行保存,并在以后的任意时间对其进行恢复(通过 restore() 方法)。 多段文字 如果是连续多段不同格式的文字,如果让用户每段文字都指定坐标是不现实的,因为上一段文字的长度是不固定的,这里的解决方案是使用[代码]ctx.measureText[代码] (基础库 1.9.90 开始支持)Api来计算一段文字的宽度,记住这里返回宽度的单位是px(坑),从而知道下一段文字的坐标。 超长文字和多行文字缩略问题 设置文字的宽度,通过[代码]ctx.measureText[代码]知道文字的宽度,如果超出设定的宽度,超出部分使用“…”代替;对于多行文字,经测试发现字体的高度大约等于字体大小,并提供lineHeight参数让用户可以自定义行高,这样我们就可以知道下一行的y轴坐标了。 矩形包含文字 这个同样使用[代码]ctx.measureText[代码]接口,从而控制矩形的宽度,当然这里用户还可以设置paddingLeft和paddingRight字段; 文字的垂直居中问题可以设置文字的基线对齐方式为middle([代码]this.ctx.setTextBaseline('middle');[代码]),设置文字的坐标为矩形的中线就可以了;水平居中[代码]this.ctx.setTextAlign('center');[代码]; [图片] 多个元素间的层级问题 由于canvas没有Api可以设置绘制元素的层级,只能是根据后绘制层级高于前面绘制的方式,所以需要用户传入zIndex字段,利用数组排序(Array.prototype.sort)后再根据顺序绘制。 图片尺寸和渲染尺寸不一致问题 绘制图片我们使用[代码]ctx.drawImage()[代码]API; 如果使用[代码]drawImage(dx, dy, dWidth, dHeight)[代码],图片会压缩尺寸以适应绘制的尺寸,图片会变形,如下图: 在基础库1.9.0起支持[代码]drawImage(sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)[代码],sx和sy是源图像的矩形选择框左上角的坐标,sWidth和sHeight是源图像的矩形选择框的宽度和高度,如下图: [图片] 如果绘制尺寸比源图尺寸宽,那么绘制尺寸的宽度就等于源图宽度;反之,绘制尺寸比源图尺寸高,那么绘制尺寸的高度等于源图高度; 我们可以通过[代码]wx.getImageInfo[代码]Api获取源图的尺寸; canvas转图片 在canvas绘制完成后调用[代码]wx.canvasToTempFilePath[代码]Api将canvas转为图片输出,这样需要注意,[代码]wx.canvasToTempFilePath[代码]需要写在[代码]this.ctx.draw[代码]的回调中,并且在组件中使用这个接口需要在第二个入参传入this(坑),如下 [代码]this.ctx.draw(false, () => { wx.canvasToTempFilePath({ canvasId: 'canvasid', success: (res) => { wx.hideLoading(); this.triggerEvent('success', res.tempFilePath); }, fail: (err) => { wx.hideLoading(); this.triggerEvent('fail', err); } }, this); }); [代码] IOS 6.6.7 clip问题 在IOS 6.6.7版本中clip方法连续裁剪图片时,只有第一张有效,这是微信的bug,官方也证实了(https://developers.weixin.qq.com/community/develop/buglist) 关于获取canvas实例 我们可以使用[代码]wx.createCanvasContext[代码]获取小程序实例,但在组件中使用切记第二个参数需要带上this,如下 [代码]this.ctx = wx.createCanvasContext('canvasid', this); [代码] 如何使用组件 https://github.com/jasondu/wxa-plugin-canvas
2019-02-22 - 【优化】利用函数防抖和函数节流提高小程序性能
大家好,上次给大家分享了swiper仿tab的小技巧: https://developers.weixin.qq.com/community/develop/article/doc/000040a5dc4518005d2842fdf51c13 [代码]今天给大家分享两个有用的函数,《函数防抖和函数节流》 函数防抖和函数节流是都优化高频率执行js代码的一种手段,因为是js实现的,所以在小程序里也是适用的。 [代码] 首先先来理解一下两者的概念和区别: [代码] 函数防抖(debounce)是指事件在一定时间内事件只执行一次,如果在这段时间又触发了事件,则重新开始计时,打个很简单的比喻,比如在打王者荣耀时,一定要连续干掉五个人才能触发hetai kill '五连绝世'效果,如果中途被打断就得重新开始连续干五个人了。 函数节流(throttle)是指限制某段时间内事件只能执行一次,比如说我要求自己一天只能打一局王者荣耀。 这里也有个可视化工具可以让大家看一下三者的区别,分别是正常情况下,用了函数防抖和函数节流的情况下:http://demo.nimius.net/debounce_throttle/ [代码] 适用场景 函数防抖 搜索框搜索联想。只需用户最后一次输入完,再发送请求 手机号、邮箱验证输入检测 窗口resize。只需窗口调整完成后,计算窗口大小。防止重复渲染 高频点击提交,表单重复提交 函数节流 滚动加载,加载更多或滚到底部监听 搜索联想功能 实现原理 [代码] 函数防抖 [代码] [代码]const _.debounce = (func, wait) => { let timer; return () => { clearTimeout(timer); timer = setTimeout(func, wait); }; }; [代码] [代码] 函数节流 [代码] [代码]const throttle = (func, wait) => { let last = 0; return () => { const current_time = +new Date(); if (current_time - last > wait) { func.apply(this, arguments); last = +new Date(); } }; }; [代码] [代码] 上面两个方法都是比较常见的,算是简化版的函数 [代码] lodash中的 Debounce 、Throttle [代码] lodash中已经帮我们封装好了这两个函数了,我们可以把它引入到小程序项目了,不用全部引入,只需要引入debounce.js和throttle.js就行了,链接:https://github.com/lodash/lodash 使用方法可以看这个代码片段,具体的用法可以看上面github的文档,有很详细的介绍:https://developers.weixin.qq.com/s/vjutZpmL7A51[代码]
2019-02-22 - 「分享」高性能双列瀑布流极简实现(附示例)❤️
前言 在日常开发过程中,经常会有双列瀑布流场景的需求出现,如商品列表、文章列表等,本文将简单介绍这种情景下如何高效、精准的实现双列瀑布流场景,支持刷新、加载更多等,实现效果如下。 [图片] [图片] 开发思路 瀑布流视图有一种参差的美感,常规列表布局如 flex wrap 等由于存在行高度限制,无法让第二行的 item 对齐上一行最矮处,因此,瀑布流布局时采用双列 scrollview 的 flex 布局。 参差布局的实现,采用代码计算左右两列的高度,然后对左右两列总高度进行比较,新加入的 item 总是排在总高度较小的那列后面。 计算时可以尽可能的缓存高度,例如左右两列高度在每次计算时都缓存起来,有新的 item 加入列表时直接增加左右两列高度即可,不需要重新从头计算。 index.js [代码]const tplWidth = (750 - 24 - 8) / 2; const tplHeight = 595; // plWidth * 1.66 newPhotos.forEach(photo => { const { height, width } = photo let photoHeight = tplWidth if (height > width) { photoHeight = tplHeight photo.display = 'long' } else { photo.display = 'short' } if (leftHeight < rightHeight) { leftList.push(photo) leftHeight += photoHeight } else { rightList.push(photo) rightHeight += photoHeight } }) [代码] index.wxml [代码]<!-- list --> <view class='list'> <!-- left --> <view class='left-list'> <block wx:for="{{leftList}}" wx:key="{{item._id}}"> <cell photo="{{item}}" bindclick='onCellClicked' /> </block> </view> <!-- right --> <view class='right-list'> <block wx:for="{{rightList}}" wx:key="{{item._id}}"> <cell photo="{{item}}" bindclick='onCellClicked' /> </block> </view> </view> [代码] index.css [代码].list { display: flex; flex: 1; position: relative; flex-direction: row; justify-content: space-between; padding-left: 12rpx; padding-right: 12rpx; padding-top: 8rpx; } .left-list { display: flex; position: relative; flex-direction: column; width: 359rpx; } .right-list { display: flex; position: relative; flex-direction: column; width: 359rpx; } [代码]
2019-02-27 - 组件事件监听的简述
其实我自己也是半路出家,从没系统的学过小程序,文档更是没仔细读。遇到问题就查,看文档,看demo,完完全全一个新手。这两天用到组件,需要传递事件的时候看文档也没看明白。不过最后跌跌撞撞算是完成了。在这里记录下,分享给新手,大神请绕道,喷子且轻喷。 创建组件 在根目录下,创建一个components目录,用来放所有组件。 components目录下创建一个目录,这个就是你要新建的组件。 接着右键-> 新建Component,四个文件就直接给你创建好了 wxml 和 wxss 就不说了跟Page一样去写。 一般情况下我们都是要在自己的主页或者哪个Page下使用这个组件,有时候需要出发一些事件监听,看了很多Demo都没整明白,这里我试着尽量描述清楚,有不足的地方还请指正! 在 使用组件的页面 的 json 中,我们需要声明这个组件 [图片] 然后在 使用组件的页面 的 wxml 中 使用这个组件 [图片] 事件监听 组件部分 首先组件的wxml中要有一个bind监听,这里点击就会出发setText函数 [图片] 然后组件的 js 中的setText函数要有this.triggerEvent() [图片] 这里组件的就差不多了。现在我们去别的页面调用 调用页面部分 首先你得使用这个组件吧,上面讲过了怎么创建和使用。 在wxml使用组件标签的时候,bind: + this.triggerEvent()传的字符串 + =“调用页面 js 中的函数” [图片] 然后你就可以在调用也的 js 的函数中去操作了 [图片] 简单说就是四步 组件的wxml :绑定一个事件函数A 组件的 js :在事件函数A中 -> 写 this.triggerEvent(‘触发名B’) 调用页的wxml : 在组件标签上 -> bind: 触发名B=“调用页的事件函数C” 调用页的 js :事件函数C -> 爱干嘛干嘛 当然实际中可能不止这么简单,需要组件的数据在调用也中操作,这就要多加两个参数,可以看这里,这里就不赘述了。 我的理解是:组件跟调用页的关系大概就是组件的js提供数据,调用页的 js 操作数据。 The end
2019-02-27 - [坑] 解决画布(Canvas)真机绘制图片(drawImage)不显示的问题
项目有一个使用二维码图片(远程url)生成海报的功能,基于微信的画布(canvas)接口实现,开发者工具生成无问题,到真机后发现图片未绘制出来( 准确的说的绘制后保存tempFile没有图片,但文字内容都是ok的 )。经过一番摸索猜测原因为真机canvas无法实时绘制远程图片资源(可能是异步、执行顺序的问题?)。 通过以下方式解决: 先使用getImageInfo接口预加载远程图片,让后用预加载的缓存图片进行绘制。 示例代码: [代码]let ctx = wx.createCanvasContext('your-canvas-id') let remote_url = 'https://xxx.com/your-image.png'; wx.getImageInfo({ src : remote_url, success : res => { //res.path 为getImageInfo预加载的缓存图片地址 ctx.drawImage( res.path , x, y, w, h); } }); [代码]
2019-02-27 - 如何监听小程序中的手势事件(缩放、双击、长按、滑动、拖拽)
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 - 小程序实现大转盘,九宫格抽奖,带跑马灯效果
基本实现功能 1,小程序仿天猫超市大转盘 2,九宫格转盘抽奖 3,积分抽奖 4,抽到的积分随机生成 5,抽奖结果可以同步到服务器(小程序云开发后台) 老规矩先看效果图 [图片] 简单说一下实现原理. 我们借助js的定时器,来执行一个加法。比如我们设置一个上限300,每过一定时间执行一次,然后我们再做一个随机数,这个随机数不停的++,直到总数大于300.就代表抽奖结束。核心代码如下。 [代码] //开始抽奖 startGame: function() { if (this.data.isRunning) return this.setData({ isRunning: true }) var _this = this; var indexSelect = 0 var i = 0; var timer = setInterval(function() { indexSelect++; let randomNum = Math.floor(Math.random() * 10) * 10; //可均衡获取0到90的随机整数 i += randomNum; if (i > 300) { //去除循环 clearInterval(timer) //获奖提示 let jifen = 1; let selectNum = _this.data.indexSelect console.log("选号:" + selectNum ); if (selectNum===0) { jifen = 2; } else if (selectNum === 1) { jifen = 3; } else if (selectNum === 2) { jifen = 4; } else if (selectNum === 3) { jifen = 5; } else if(selectNum === 4) { jifen = 6; } else if(selectNum === 5) { jifen = 8; } else if (selectNum === 6) { jifen = 10; } wx.showModal({ title: '恭喜您', content: '获得了' + jifen + "积分", showCancel: false, //去掉取消按钮 success: function(res) { if (res.confirm) { _this.setData({ isRunning: false }) } } }) } indexSelect = indexSelect % 8; _this.setData({ indexSelect: indexSelect }) }, (200 + i)) } [代码] 完整源码可以加我微信,如果有关于小程序的问题,可以加我微信2501902696(备注小程序)
2019-03-05 - cover-view评论弹幕自动滚动到底部的做法
在目前没有同层渲染的原生组件上加入文字、聊天、弹幕等就要用cover-view来解决了。 聊天每增加一条就自动滚动,上代码 此代码准确获取需要滚动的高度,解决苹果手机上的scroll-top过大导致溢出问题 <cover-view class=‘chat’ scroll-top="{{scrollTop}}" style=“bottom:105rpx;z-index:999999;{{!showbtn ? ‘display:none;’:‘display:block;’}}” bindtap=“hideshow”> <cover-view id=“bianjie”> <cover-view class=‘item’ wx:for="{{chatlist}}" wx:for-item=“record” wx:for-index=“recordid” wx:key=“index” > <cover-view class=‘name’>{{record.nickname}}</cover-view> <cover-view class=‘text’>{{record.content}}</cover-view> </cover-view> </cover-view> </cover-view> <button formType=“submit” class="btn-send ">发送</button> js中处理 //发送聊天 formSubmit: function (e) { //处理聊天内容 [代码]//设置scrollTop this.queryMultipleNodes(); [代码] }, // 获取节点信息并设置scrollTop queryMultipleNodes: function () { var query = wx.createSelectorQuery(), e = this; wx.createSelectorQuery().in(e).select(’.chat’).boundingClientRect(function (res) { e.setData({ chatbottom: res.bottom, }) }).exec() query.in(e).select(’#bianjie’).boundingClientRect(function (res) { if (res.bottom != undefined && res.bottom != ‘’ && res.bottom != null && typeof (res.bottom) != ‘undefined’) { //var setsbottom = Math.ceil(res.bottom) ; var setsbottom = setsbottom = Math.ceil(parseInt(res.bottom) - parseInt(e.data.chatbottom)); e.setData({ scrollTop: setsbottom }); } }).exec() }, 初次分享,献丑了,如有不对的地方请各位大神多多指正,谢谢(-)。
2019-03-08 - 在前端使用 Protobuf
Protocol Buffers,是Google公司开发的一种数据描述语言,类似于XML能够将结构化数据序列化,可用于数据存储、通信协议等方面。 本文和大家聊聊怎样在前端使用 protobuf,在开始前先聊聊 JSON。 关于 JSON 我们前端通常是使用 JSON 格式作为数据格式,JSON也有优点: 1、原生支持 符合 JavaScript 原生语法,书写简单,直观一目了然 2、解析 我们可以使用 JSON.parse 和 JSON.stringify 来做反序列化和序列化,性能好,这一切不需要使用第三方库。 3、不需要描述文件 这点还是优于 PB 的,PB文件需要描述文件来做对应的解析,而 JSON 不需要描述文件。 4、抓包方便 我们可以很方便地通过 Chrome DevTools , Fiddler,Whistle 等抓包工具可以查看返回的 JSON 数据。 关于 protobuf 要使用 protobuf 首先要一个数据结构的描述文件,然后使用特殊生成的源代码轻松的在各种数据流中使用各种语言进行编写和读取结构数据。 优点: 1、体积小 protobuf 对数据序列化后,数据大小可约小3倍(当然,压缩比还要看具体内容)。 2、语言无关、平台无关 ProtoBuf 支持 Java、C++、Python 等多种语言,支持多个平台。 3、高效 即比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单。 4、扩展性、兼容性好 你可以更新数据结构,而不影响和破坏原有的旧程序。 为什么在前端使用 protobuf protobuf 已经被后台广为使用,而且优点那么多,为什么前端不愿意使用呢?我认为很重要的两点: 1、不便于抓包和调试,因为 protobuf 需要依赖对应的描述文件才可以正确地把数据解析出来,而JSON不需要,这就导致在开发和测试场景下不便于抓包。 2、需要先把 protobuf 描述文件异步加载,这就多了一次网络请求,或者打包到业务代码中,导致业务代码变大。 3、解析后有很多冗余的字段,需要前端过滤和整理成可用的 JSON 结构体。 有了这些问题为什么还折腾 protobuf 呢? 其实在某些场景下比如单个请求数据量较大,上几M 的回包,当然也可以选择分包拉取,但这样无论并发加载还是阻塞加载,如果需要读取到某片数据,那么你的业务代码写起来将是将是非常复杂。 ##数据对比 后端通过 protobuf 指令生成的 protobuf 描述文件,需要依赖于 google-protobuf.js。 拿我们的回放业务来做对比,课堂进入回放模式时需要加载互动字幕文件,如果这些数据使用 protobuf 会有什么变化? 先看看企鹅辅导的回放字幕文件结构: [图片] 注:因为数据有嵌套 protobuf ,所以要分为整体字幕文件 和 具体的交互 pb 描述文件,待解析到具体交互时,才会使用 交互 pb 描述文件去解析 。 文件名 说明 体积 pbplaybackinfo_pb.js 整体字幕 pb 描述文件 83KB chat_pb.js 聊天 pb 描述文件 473KB envelope_pb.js 红包 pb 描述文件 83KB exam_pb.js 考试 pb 描述文件 35KB vote_pb.js 投票 pb 描述文件 90KB 注:以上 pb 描述文件整体 zip 之后 52 KB。 google-protobuf.js 大小:157 KB,zip 后 33KB。 一共 zip 后 85 KB (机子压缩工具没 gzip 格式,这里暂用 zip 。gzip 比 zip 压缩比更高) (1)两种格式字幕文件体积对比 这里抽样对比pb格式的二进制字幕文件体积与对应的json格式的字幕文件的体积 pb字幕文件体积 json字幕文件体积 json体积/pb体积 4.3M 16M 3.72 6.5K 16K 2.46 40K 143K 3.575 23K 97K 4.21 通过抽样对比可得,json格式的字幕文件的体积基本是pb格式的2倍以上,最多甚至是4倍以上,体积差异比较大。 (2)浏览器解析pb字幕文件速度对比 因为字幕文件中的信息节点较多,这里调研解析pb字幕文件的速度。使用的字幕文件是目前体积最大的文件,体积是4.3M。 解析字幕文件的过程分为两步: 解析外层数据,得到json格式概要信息 + 二进制具体数据 解析内层数据,得到json格式具体数据 [图片] 字幕pb 文件(4.3M)做测试得出耗时数据: 解析步骤 耗时 只解析外层数据 279ms 解析外层数据+解析内层数据 1995ms 总结:如果一次解析外层数据和内层数据,会耗时较长,而如果只解析内层数据只需要279ms,比较短。在实现的时候只需要解析外层数据得到概要信息(时间信息、消息类型等),在具体展示的时候才解析内层数据,这样是可以大幅降低初次解析数据导致的耗时。 兼容性 dcodeIO 给出的浏览器兼容 IE9+ [图片] Show you the code 后端提供给前端 pb 描述文件需要执行以下命令来生成 js 可用的 pb 描述文件 [代码]protoc --js_out=library=myproto_libs,binary:. messages.proto base.proto [代码] 假设前端已经拿到了 pb 描述文件 pbplaybackinfo_pb.js ,但描述文件依赖于 google-protobuf.js ,要整体打包后使用。 那么首先 npm i google-protobuf 在业务代码中开始引入: [代码]const messages = require('./pbplaybackinfo_pb'); const playbackRspBody = new messages.PlaybackRspBody; fetch('https://fudao.qq.com/pb/test.pb').then((res) => { res.arrayBuffer().then((res) => { console.time('pb'); const arrayBuffer = new Uint8Array(res); console.log(messages.PlaybackRspBody.deserializeBinary(arrayBuffer, playbackRspBody).toObject()); console.timeEnd('pb'); }); }); [代码] 接着我们这里的例子是异步拉取测试用的 pb 文件,假设是 test.pb。 因为 pb 数据文件是二进制的,所以这里需要使用 arrayBuffer() 的方式,待拉取完毕后再转 Uint8Array 类型。什么是 arrayBuffer ,什么是 uint8Array? google-protobuf 提供的反序列化接口 deserializeBinary 必须是由对应的 pb 描述中的静态方提供。所以上面使用了 [代码]messages.PlaybackRspBody.deserializeBinary( )[代码] 到这里你拿到是未格式化的二进制 arrayBuffer,所以一定要 .toObject( ) ,官方文档没有提及到这个方法,在这我也折腾了比较长的时间。 最后 前端使用 json 还是 protobuf,还是看具体场景,普通的数据量小的接口当然是 JSON 最好,因为无论从可读性和调试成本上来看,这是最做优的。如果是数据量较大的情况下,需要综合业务代码的改造成本和用户体验等多方面做权衡。
2019-03-08 - 小程序中使用防抖函数
这几天看了很多关于防抖函数的博客,我是在微信小程序中使用,在此总结一下关于防抖函数的知识。 为什么需要防抖函数? 防抖函数适用的是【有大量重复操作】的场景,比如列表渲染之后对每一项进行操作。 函数代码: [代码]var timer; debounce: function (func, wait) { return () => { clearTimeout(timer); timer = setTimeout(func, wait); }; }, [代码] 参数: func:需要防抖的函数; wait:number类型,setTimeout的时间参数; 代码分析: 命名一个叫做debounce的函数,参数有两个(func,wait),return一个函数,内容为清除计时器,然后设置计时器,计时器的意思是:在wait时间后执行func。 清除计时器是整个函数的核心,因为防抖需要不停地清除计时器,最后在计时器结束后触发func来达到效果。 防抖函数的调用方法 example: [代码]this.debounce(this.函数名,3000)() [代码] 在使用这个函数的时候我遇到了一些问题: 因为微信小程序中很多地方都需要使用this.setData,如果对于this指向的理解不深入的话,很容易出现以下情况: 1:this==undefined; 2:Error:data is not defined; 等等一些列关于this的问题。 解决方法: [代码]this.debounce(this.函数名.bind(this),3000)() [代码] 使用bind(this)把this指向传到函数内部就解决了。
2019-03-19 - 一文读懂 babel7 的配置文件加载逻辑
近期,在波洞星球的PC官网项目中,我们采用了新版的 babel7 作为 ES 语法转换器。而 babel7 中的一大变更就是对配置文件的加载逻辑进行了改进,然而实际上对于不熟悉 babel 配置逻辑的朋友往往会带来更多问题。本文就是 babel7 配置文件的中文指南,它是英语渣渣的救星,是给懒人送到口边的一道美味。如有错误 概不负责 欢迎指正。 前言 babel7 从 2018年3月开始进入 alpha 阶段,时隔5个月直到 2018年8月份 release 第一个版本,目前的最新版是2019年2月26号发布的 7.3.4. 时光如梭,在这美好的 9012 年,ES2019 都快要发布了的时刻,我想: 是时候用一用 babel7 了。 本文不是 babel7 的升级教程,而是对 babel7 的新变化和配置逻辑的一点心得。babel7 对monorepo 结构项目的优化恰好符合我们目前项目架构的预期,这简化了我们配置的复杂度,但其难以理解的配置加载逻辑,却让我踩了不少坑,这也正是本文的来源。 说点变化 在开始讲 babel7 的配置逻辑之前,我们先从以下几个方面来啰嗦几句 babel7 所做的变更及其逻辑意义。 proposal 语法特性 在历史上(babel6)的时代,人们通常使用 babel 提供的 preset-stage 预设来体验 ES6 之后的处于建议阶段的语法特性。例如做如下的 babel 配置: [代码]"presets": ["es2015", "react", "stage-0"] [代码] 其中,es2015 预设会包含 ES6 标准中所有语法特性;stage-0预设会包含当前(安装该预设npm包的时刻) 的 ES 语法进展中的 stage 0到3的特性(数字小的包含数字大的)。但事实上 babel 官方这样提供 stage 预设,会有不少问题 例如: 随着 es 标准的不断发展,大量的新特性几乎已经成为标准。与此同时,stage0-3阶段的特性必然也发生变化。可以说,stage0-3的阶段特性他们是不稳定的,极有可能在某个时机被TC39委员会除名、变更阶段、改变语法。尽管 babel-preset-* 预设会跟随TC39 保持一致的更新, 但这样的用法需要使用者也不断保持更新 才能跟标准一致 历史上的 preset-es2015 配合 preset-stage-0 的做法极易产生疑惑,例如没有人知道他所需要的特性在stage几 一个语言特性如果从 stage3 变更为 stage4,往往会导致以前的 stage0(包含了1、2、3) 的配置出问题。因为特性推进后,新的stage0中就不再包含该特性内容,但使用者可能不知道要把该特性所在的 ES标准 加入到配置中 大量的社区工具 eslint 等等都依赖 babel;babel 的 preset-stage 预设更新就会导致这些社区工具频频出现问题。 如今,babel 官方认为,把不稳定的 stage0-3 作为一种预设是不太合理的,因此废弃了 stage 预设,转而让用户自己选择使用哪个 proposal 特性的插件,这将带来更多的明确性(用户无须理解 stage,自己选的插件,自己便能明确的知道代码中可以使用哪个特性)。所有建议特性的插件,都改变了命名规范,即类似 [代码]@babel/plugin-proposal-function-bind[代码] 这样的命名方式来表明这是个 proposal 阶段特性。 ES 标准特性 对于正经的 ES 标准特性,babel从6开始就建议使用 babel-preset-env 这个能根据环境进行自动配置的预设。到了 babel7,我们就可以完全告别这几个历史预设了: preset-es2015/es2016/es2017/latest 为什么 preset-env 要更好呢? 我认为,对于开发者而言,关注目标用户平台(兼容哪些浏览器)要比关注 “编译为哪份ES标准” 要更易理解。把选择编译插件的事情交给 preset-env 就好了。它会根据 compat table 和你设置的目标用户平台选择正确的插件。 polyfill 跟 stage 预设的结局一样,对于处于建议阶段的特性,polyfill里面也移除了对他们的支持。 以前的 babel-polyfill 是这么实现的: [代码]import "core-js/shim"; // included < Stage 4 proposals import "regenerator-runtime/runtime" [代码] 现在的 @babel/polyfill 就直接引入 core-js v2 的属于ES正式标准的模块。这意味着,如果你需要使用处于 proposal 阶段的语法特性,你需要手工 import core-js 中的对应模块。 命名空间 从 babel7 开始,所有的官方插件和主要模块,都放在了 @babel 的命名空间下。从而可以避免在 npm 仓库中 babel 相关名称被抢注的问题。有必要说一下的,比如 @babel/node @babel/core @babel/clil @babel/preset-env transform-runtime 以前的 [代码]babel-transform-runtime[代码] 是包含了 helpers 和 polyfill。而现在的 [代码]@babel/runtime[代码] 只包含 helper,如果需要 polyfill,则需主动安装 core-js 的 runtime版本 [代码]@babel/runtime-corejs2[代码] 。并在 [代码]@babel/plugin-transform-runtime[代码] 的插件中做配置。 说重点: 配置 这是本文的重点,先来看一段 babel7 对配置的变更说明 Babel has had issues previously with handling node_modules, symlinks, and monorepos. We’ve made some changes to account for this: Babel will stop lookup at the package.json boundary instead of looking up the chain. For monorepo’s we have added a new babel.config.js file that centralizes our config across all the packages (alternatively you could make a config per package). In 7.1, we’ve introduced a rootMode option for further lookup if necessary. 段落的意思大概有这么几点: Babel将停止在package.json边界查找而不是查找链。译者注:这说明以前babel会递归向上查找babelrc 而现在检索行为会停在package.json所在层级。这可以解决部分符号链接的js向上查找babelrc错乱的问题。 添加了一个新的项目全局babel.config.js文件,可以将整个项目的配置集中在所有包中。译者注:除了新增的这个全局配置,也可以同时支持以前的基于文件的.babelrc的配置 引入了一个rootMode选项,以便在必要时按一定策略查找 babel.config.js 除此之外,babel7 还有一个特性是: 默认情况下,不会加载monorepo项目的任何独立子项目中的 .babelrc 文件 然而,对上面的解释,你可能: 每个字都认识,连在一起却不知道在说什么。下面我们来剖析一下 概念 为了理解 babel7 的配置逻辑,我们就以 babel7 真正所解决的痛点 [monorepo 类型的项目] 为例来剖析。在此之前,我们需要预先确定几个概念。 monorepo。这是个自造词。按我的理解,它的含义是说 [代码]单个大项目但是包含多个子项目[代码] 的含义。如果还是不能理解的话,就把 项目 二字 换成 [代码]npm模块包[代码] (以package.json文件作为分界线)。即 [代码]单个npm包中又包含多个子npm包[代码] 的项目。 例如,波洞的 PC 版采用的是 Node.js 作为前端接入层的方式,在我们的项目结构组织上,是这样的: [代码]|- backend |-package.json |- frontend |-package.json |- node_modules |- config.js |- babel.config.js |- package.json [代码] [代码] 这就是典型的 monorepo 结构。 [代码] 全局配置。在 babel 文档中又叫 项目级别的配置,特指 babel.config.js。如上图的monorepo结构,其 babel.config.js 就是全局配置/项目配置,该 babel 配置对 backend、frontend、甚至 node_modules 中的模块全部生效。 局部配置。在 babel 文档中可能叫 相对于文件的配置。这种配置就是特指的 .babelrc 或 .babelrc.js 了。他们的生效范围是与待编译文件的位置有关的。 规则 懂了几种配置文件的概念和作用范围之后,我们就可以来根据文档和代码测试结果来精确描述 babel7 的配置规则。这里我们直接以 monorepo 类型项目为例来说,因为普通项目会更简单。 下文中可能用到的名词解释: 我们用 package 来代指一个具有独立 package.json 的项目,如上面案例中的 frontend 可以称作一个 package,backend也可以称作一个package; 我们用 相对配置 这个名词来表达所谓的 .babelrc 和 .babelrc.js,用全局配置来代指 babel.config.js这份配置 对monorepo类型项目,babel7 的处理逻辑是: 【全局配置】全局配置 babel.config.js 里的配置默认对整个项目生效,包括node_modules。除非通过 exclude 配置进行剔除。 【全局配置】全局配置中如果没有配置 babelrcRoots 字段,那么babel 默认情况下不会加载任何子package中的相对配置(如.babelrc文件)。除非在全局配置中通过 babelrcRoots 字段进行配置。 【全局配置】babel 全局配置文件所在的位置就决定了你的项目根目录在哪里,默认就是执行babel的当前工作目录,例如上面的例子,你在根目录执行babel,babel才能找到babel.config.js,从而确定该monorepo的根目录,进而将配置对整个项目生效 【相对配置】相对配置可被加载的前提是在 babel.config.js 中配置了 babelrcRoots. 如 babelrcRoots: [’.’, ‘./frontend’],这表示要对当前根目录和frontend这个子package开启 .babelrc 的加载。(注意: 项目根目录除了可以拥有一个 babel.config.js,同时也可以拥有一个 .babelrc 相对配置) 【相对配置】相对配置加载的边界是当前package的最顶层。假设上文案例中要编译 frontend/src/index.js 那么,该文件编译时可以加载 frontend 下的 .babelrc 配置,但无法向上检索总项目根目录下的 .babelrc 实战 还是以上面的代码结构为例。 [代码]|- backend |-package.json |- frontend |-package.json |- node_modules |- config.js |- babel.config.js |- package.json [代码] 该案例中,我们思考发现,[代码]我们需要利用 babel7 的全局配置能力[代码]。原因在于,monrepo 中存在多个 子 package。由于 babel7 默认检索 babelrc 的边界是 当前package。因此每个package中撰写的babelrc只会对当前package生效,这会导致我们的frontend中依赖根目录的config.js时无法得到正确的编译;另一个问题是: frontend和backend中的相同的babel配置部分无法共享 存在一定冗余。为此,我们需要在项目根目录设置一个 babel.config.js的配置,用它再配合babelrc来做babel配置的共享和融合。 但是,问题很快来了:[代码]当工作目录不在根目录时,无法加载到全局配置[代码]。我们的前端编译脚本通常放置在 frontend目录下,(我们执行编译的工作目录是在 frontend 中),此时 babel build 行为的 工作目录 便是 frontend. 由于 babel 默认只在当前目录寻找 babel.config.js 这个全局配置,因此会导致无法找到根目录的 babel.config.js,这样我们所设想的整个项目的全局配置就无法生效。 幸好,babel7 提供了 rootMode 选项,可以将它指定为 “upward”, 这样babel 会自动向上寻找全局配置,并确定项目的根目录位置。 设置方法: CLI: babel --rootMode=upward webpack: 在 babel-loader 的配置上设置 rootMode: ‘upward’ 现在,全局配置有了,我们可以在里面配置 babel 转译规则,它可以对全项目生效,frontend下的 vue.js 编译自然没有问题了。 不过,假设我们 backend 项目中也要使用 babel 转译(目前我们实际在 backend 中并没有使用,因为我们认为只图esmodule而多加一层编译得不偿失),那么[代码]必然 backend 与 frontend 中的编译配置是不同的[代码],frontend 需要加载 vue 的 jsx 插件和polyfill (useBuiltIns: usage,modules: false),而backend只需要转译基本模块语法(modules: true, useBuiltIns: false)。该场景的解决方案便是,为每个子 package 提供独立的 .babelrc 相对配置,在全局 babel.config.js 中设置共用的配置。此时项目组织结构如下: [代码]|- backend |- .babelrc.js |-package.json |- frontend |- .babelrc.js |-package.json |- node_modules |- config.js |- .babelrc.js // 这份配置在本场景下不需要(如果根目录下的代码有区别于子package的babel配置,则需要使用) |- babel.config.js |- package.json [代码] 根目录的 babel.conig.js 配置应该如下: [代码]const presets = [ // 根、frontend、backend 的公共预设 ] const plugins = [ // 根、frontend、backend 的公共插件 ] module.exports = { presets, plugins, babelrcRoots: ['.', './frontend', './backend'] // 允许这两个子 package 加载 babelrc 相对配置 } [代码] 以为此时已经高枕无忧了?navie,由于我们前端 Vue.js 采用 webpack 打包。实际开发过程中发现,这种配置会造成 webpack 打包模块时出现故障,故障原因在于:[代码]同一个模块中错误混用 esmodule 和 commonjs 语法会造成 webpack故障[代码]。 前文讲到 全局配置 global.config.js 会作用到 [代码]整个项目[代码],甚至包括 node_modules。因此babel编译时会同时编译 node_modules 下的模块,虽然模块作者不可能在一个js文件中混用不同模块语法,但他们作为释出包 通常是commonjs的模块语法。 而preset-env预设在编译时会通过 [代码]usage[代码] 方式 默认注入import语法的 polyfill Since Babel defaults to treating files are ES modules, generally these plugins/presets will insert import statements 这便是蛋疼的来源:babel加载过的node_modules模块会变成 同一个js文件里既有commonjs语法又有esmodule语法。 解决方案:不要对 node_modules 下的模块采用babel编译。我们需要在 babel.config.js 配置中增加选项: [代码]exclude: /node_modules/ [代码] 总结 至此,我们的 monorepo 项目就可以使用一份 全局配置+两份相对配置,实现分别对 前端和后端 进行合理的ES6+语法的编译了。这是我们配置工程师的一小步,但是前端走向未来语法的一大步。 总结 babel7 的配置加载逻辑如下: babel.config.js 是对整个项目(父子package) 都生效的配置,但要注意babel的执行工作目录。 .babelrc 是对 [代码]待编译文件[代码] 生效的配置,子package若想加载.babelrc是需要babel配置babelrcRoots才可以(父package自身的babelrc是默认可用的)。 任何package中的babelrc寻找策略是: 只会向上寻找到本包的 package.json 那一级。 node_modules下面的模块一般都是编译好的,请剔除掉对他们的编译。如有需要,可以把个例加到 babelrcRoots 中。 虽然写的很乱,但您有收获吗,有的话点个赞吧. 或许你还没有看明白。没关系,知道最终的配置代码怎么粘贴就好了~
2019-03-30 - 拥抱更底层技术——从CSS变量到Houdini
0. 前言 平时写CSS,感觉有很多多余的代码或者不好实现的方法,于是有了预处理器的解决方案,主旨是write less &do more。其实原生css中,用上css变量也不差,加上bem命名规则只要嵌套不深也能和less、sass的嵌套媲美。在一些动画或者炫酷的特效中,不用js的话可能是用了css动画、svg的animation、过渡,复杂动画实现用了js的话可能用了canvas、直接修改style属性。用js的,然后有没有想过一个问题:“要是canvas那套放在dom上就爽了”。因为复杂的动画频繁操作了dom,违背了倒背如流的“性能优化之一:尽量少操作dom”的规矩,嘴上说着不要,手倒是很诚实地[代码]ele.style.prop = <newProp>[代码],可是要实现效果这又是无可奈何或者大大减小工作量的方法。 我们都知道,浏览器渲染的流程:解析html和css(parse),样式计算(style calculate),布局(layout),绘制(paint),合并(composite),修改了样式,改的环节越深代价越大。js改变样式,首先是操作dom,整个渲染流程马上重新走,可能走到样式计算到合并环节之间,代价大,性能差。然后痛点就来了,浏览器有没有能直接操作前面这些环节的方法呢而不是依靠js?有没有方法不用js操作dom改变style或者切换class来改变样式呢? 于是就有CSS Houdini了,它是W3C和那几个顶级公司的工程师组成的小组,让开发者可以通过新api操作CSS引擎,带来更多的自由度,让整个渲染流程都可以被开发者控制。上面的问题,不用js就可以实现曾经需要js的效果,而且只在渲染过程中,就已经按照开发者的代码渲染出结果,而不是渲染完成了再重新用js强行走一遍流程。 关于houdini最近动态可点击这里 上次CSS大会知道了有Houdini的存在,那时候只有cssom,layout和paint api。前几天突然发现,Animation api也有了,不得不说,以后很可能是Houdini遍地开花的时代,现在得进一步了解一下了。一句话:这是css in js到js in css的转变 1. CSS变量 如果你用less、sass只为了人家有变量和嵌套,那用原生css也是差不多的,因为原生css也有变量: 比如定义一个全局变量–color(css变量双横线开头) [代码]:root { --color: #f00; } [代码] 使用的时候只要var一下 [代码].f{ color: var(--color); } [代码] 我们的html: [代码]<div class="f">123</div> [代码] 于是,红色的123就出来了。 css变量还和js变量一样,有作用域的: [代码]:root { --color: #f00; } .f { --color: #aaa } .g{ color: var(--color); } .ft { color: var(--color); } [代码] html: [代码] <div className="f"> <div className="ft">123</div> </div> <div className=""> <div className="g">123</div> </div> [代码] 于是,是什么效果你应该也很容易就猜出来了: [图片] css能搞变量的话,我们就可以做到修改一处牵动多处的变动。比如我们做一个像准星一样的四个方向用准线锁定鼠标位置的效果: [图片] 用css变量的话,比传统一个个元素设置style优雅多了: [代码]<div id="shadow"> <div class="x"></div> <div class="y"></div> <div class="x_"></div> <div class="y_"></div> </div> [代码] [代码] :root{ --x: 0px; --y: 0px; } body{ margin: 0 } #shadow{ width: 50%; height: 600px; border: #000 1px solid; position: relative; margin: 0; } .x, .y, .x_, .y_ { position: absolute; border: #f00 2px solid; } .x { top: 0; left: var(--x); height: 20px; width: 0; } .y { top: var(--y); left: 0; height: 0; width: 20px; } .x_ { top: 600px; left: var(--x); height: 20px; width: 0; } .y_ { top: var(--y); left: 100%; height: 0; width: 20px; } [代码] [代码]const style = document.documentElement.style shadow.addEventListener('mousemove', e => { style.setProperty(`--x`, e.clientX + 'px') style.setProperty(`--y`, e.clientY + 'px') }) [代码] 那么,对于github的404页面这种内容和鼠标位置有关的页面,思路是不是一下子就出来了 2. CSS type OM 都有DOM了,那CSSOM也理所当然存在。我们平时改变css的时候,通常是直接修改style或者切换类,实际上就是操作DOM来间接操作CSSOM,而type om是一种把css的属性和值存在attributeStyleMap对象中,我们只要直接操作这个对象就可以做到之前的js改变css的操作。另外一个很重要的点,attributeStyleMap存的是css的数值而不是字符串,而且支持各种算数以及单位换算,比起操作字符串,性能明显更优。 接下来,基本脱离不了window下的CSS这个属性。在使用的时候,首先,我们可以采取渐进式的做法: [代码]if('CSS' in window){...}[代码] 2.1 单位 [代码]CSS.px(1); // 1px 返回的结果是:CSSUnitValue {value: 1, unit: "px"} CSS.number(0); // 0 比如top:0,也经常用到 CSS.rem(2); //2rem new CSSUnitValue(2, 'percent'); // 还可以用构造函数,这里的结果就是2% // 其他单位同理 [代码] 2.2 数学运算 自己在控制台输入CSSMath,可以看见的提示,就是数学运算 [代码]new CSSMathSum(CSS.rem(10), CSS.px(-1)) // calc(10rem - 1px),要用new不然报错 new CSSMathMax(CSS.px(1),CSS.px(2)) // 顾名思义,就是较大值,单位不同也可以进行比较 [代码] 2.3 怎么用 既然是新的东西,那就有它的使用规则。 获取值[代码]element.attributeStyleMap.get(attributeName)[代码],返回一个CSSUnitValue对象 设置值[代码]element.attributeStyleMap.set(attributeName, newValue)[代码],设置值,传入的值可以是css值字符串或者是CSSUnitValue对象 当然,第一次get是返回null的,因为你都没有set过。“那我还是要用一下getComputedStyle再set咯,这还不是和之前的差不多吗?” 实际上,有一个类似的方法:[代码]element.computedStyleMap[代码],返回的是CSSUnitValue对象,这就ok了。我们拿前面的第一部分CSS变量的代码测试一波 [代码]document.querySelector('.x').computedStyleMap().get('height') // CSSUnitValue {value: 20, unit: "px"} document.querySelector('.x').computedStyleMap().set('height', CSS.px(0)) // 不见了 [代码] 3. paint API paint、animation、layout API都是以worker的形式工作,具体有几个步骤: 建立一个worker.js,比如我们想用paint API,先在这个js文件注册这个模块registerPaint(‘mypaint’, class),这个class是一个类下面具体讲到 在html引入CSS.paintWorklet.addModule(‘worker.js’) 在css中使用,background: paint(mypaint) 主要的逻辑,全都写在传入registerPaint的class里面。paint API很像canvas那套,实际上可以当作自己画一个img。既然是img,那么在所有的能用到图片url的地方都适合用paint API,比如我们来自己画一个有点炫酷的背景(满屏随机颜色方块)。有空的话可以想一下js怎么做,再对比一下paint API的方案。 [图片] [代码]// worker.js class RandomColorPainter { // 可以获取的css属性,先写在这里 // 我这里定义宽高和间隔,从css获取 static get inputProperties() { return ['--w', '--h', '--spacing']; } /** * 绘制函数paint,最主要部分 * @param {PaintRenderingContext2D} ctx 类似canvas的ctx * @param {PaintSize} PaintSize 绘制范围大小(px) { width, height } * @param {StylePropertyMapReadOnly} props 前面inputProperties列举的属性,用get获取 */ paint(ctx, PaintSize, props) { const w = (props.get('--w') && +props.get('--w')[0].trim()) || 30; const h = (props.get('--h') && +props.get('--h')[0].trim()) || 30; const spacing = +props.get('--spacing')[0].trim() || 10; for (let x = 0; x < PaintSize.width / w; x++) { for (let y = 0; y < PaintSize.height / h; y++) { ctx.fillStyle = `#${Math.random().toString(16).slice(2, 8)}` ctx.beginPath(); ctx.rect(x * (w + spacing), y * (h + spacing), w, h); ctx.fill(); } } } } registerPaint('randomcolor', RandomColorPainter); [代码] 接着我们需要引入该worker: [代码]CSS && CSS.paintWorklet.addModule('worker.js');[代码] 最后我们在一个class为paint的div应用样式: [代码].paint{ background-image: paint(randomcolor); width: 100%; height: 600px; color: #000; --w: 50; --h: 50; --spacing: 10; } [代码] 再想想用js+div,是不是要先动态生成n个,然后各种计算各种操作dom,想想就可怕。如果是canvas,这可是canvas背景,你又要在上面放一个div,而且还要定位一波。 注意: worker是没有window的,所以想搞动画的就不能内部消化了。不过可以靠外面的css变量,我们用js操作css变量可以解决,也比传统的方法优雅 4. 自定义属性 支持情况 点击这里查看 首先,看一下支持度,目前浏览器并没有完全稳定使用,所以需要跟着它的提示:[代码]Experimental Web Platform features” on chrome://flags[代码],在chrome地址栏输入[代码]chrome://flags[代码]再找到[代码]Experimental Web Platform features[代码]并开启。 [代码]CSS.registerProperty({ name: '--myprop', //属性名字 syntax: '<length>', // 什么类型的单位,这里是长度 initialValue: '1px', // 默认值 inherits: true // 会不会继承,true为继承父元素 }); [代码] 说到继承,我们回到前面的css变量,已经说了变量是区分作用域的,其实父作用域定义变量,子元素使用该变量实际上是继承的作用。如果[代码]inherits: true[代码]那就是我们看见的作用域的效果,如果不是true则不会被父作用域影响,而且取默认值。 这个自定义属性,精辟在于,可以用永久循环的animation驱动一次性的transform。换句话说,我们如果用了css变量+transform,可以靠js改变这个变量达到花俏的效果。但是,现在不需要js,只要css内部消化,transform成为永动机。 [代码]// 我们先注册几种属性 ['x1','y1','z1','x2','y2','z2'].forEach(p => { CSS.registerProperty({ name: `--${p}`, syntax: '<angle>', inherits: false, initialValue: '0deg' }); }); [代码] 然后写个样式 [代码]#myprop, #myprop1 { width: 200px; border: 2px dashed #000; border-bottom: 10px solid #000; animation:myprop 3000ms alternate infinite ease-in-out; transform: rotateX(var(--x2)) rotateY(var(--y2)) rotateZ(var(--z2)) } [代码] 再来看看我们的动画,为了眼花缭乱,加了第二个改了一点数据的动画 [代码]@keyframes myprop { 25% { --x1: 20deg; --y1: 30deg; --z1: 40deg; } 50% { --x1: -20deg; --z1: -40deg; --y1: -30deg; } 75% { --x2: -200deg; --y2: 130deg; --z2: -350deg; } 100% { --x1: -200deg; --y1: 130deg; --z1: -350deg; } } @keyframes myprop1 { 25% { --x1: 20deg; --y1: 30deg; --z1: 40deg; } 50% { --x2: -200deg; --y2: 130deg; --z2: -350deg; } 75% { --x1: -20deg; --z1: -40deg; --y1: -30deg; } 100% { --x1: -200deg; --y1: 130deg; --z1: -350deg; } } [代码] html就两个div: [代码] <div id="myprop"></div> <div id="myprop1"></div> [代码] 效果是什么呢,自己可以跑一遍看看,反正功能很强大,但是想象力限制了发挥。 自己动手改的时候注意一下,动画关键帧里面,不能只存在1,那样子就不能驱动transform了,做不到永动机的效果,因为我的rotate写的是 rotateX(var(–x2))。接下来随意发挥吧 最后 再啰嗦一次 关于houdini最近动态可点击这里 关于houdini在浏览器的支持情况 ENJOY YOURSELF!!!
2019-03-25 - 微信开发小技巧:小程序页面间如何进行通信
FlashEvent FlashEvent 小程序页面间的通信工具 - 类似于EventBus FlashEvent 在小程序中 能够简化各页面间的通信,让代码书写变得简单,能有效的解耦事件发送方和接收方,能避免复杂和容易出错的依赖性和生命周期问题。 github add: https://github.com/wuyajun7/FlashEvent 使用方式: 前置:将FlashEvent.js导入到项目的utils文件中 1、接收方js代码中 1.1 引入该类,如:let flashEvent = require(‘你的路径/utils/FlashEvent.js’); 1.2 注册FlashEvent,如:在onLoad中 [代码] flashEvent.register(flashEvent.EVENT_KEYS.FIRST_EVENT, this, function (data) { this.setData({ eventCallBack: data }) }) [代码] 1.3 注销FlashEvent,如:在onUnload中调用 flashEvent.unregister(flashEvent.EVENT_KEYS.FIRST_EVENT, this); 2、发送方js代码中 2.1 引入该类,如:let flashEvent = require(‘你的路径/utils/FlashEvent.js’); 2.2 发送事件,如:flashEvent.post(flashEvent.EVENT_KEYS.FIRST_EVENT, ‘发送的数据’); flashEvent 简单接入、方便使用
2019-03-28 - 快手的小程序把导航栏去掉了,很个性,如今我也给他去掉了
先给个图片,以前我在网上查过,问小程序如何去掉导航栏,网上一致回答说不能去掉,因为这是小程序的统一性,谁知过了不久开发文档里页面配置里就开放了这一项,手机本身版面就不大,这导航栏确实有点浪费,如今我用它做菜单,觉得很方便,如同地球有了一个空间站,我做的象棋棋谱就利用上了.把图发上来让大家看一下,不怕费时的就上小程序里搜象棋棋谱看一下,整个程序是用画布作的,走棋可以生成棋谱术语,很好玩. [图片]
2019-03-31 - iframe滚动踩坑总结
背景 由于产品想要在一个webview里以切换tab的方式同时阅读两篇图文来判断是否存在洗稿,鉴于重复实现一次图文逻辑比较蛋疼且难以维护,所以打算采用嵌入两个iframe的方案来实现,理想结果是记住两个iframe各自的滚动位置,在切换时可以回到原来的位置继续阅读。 理想是丰满的,可惜现实总是骨感的,在下文会阐述这个骨感的踩坑之旅。 iframe自身的滚动表现 如果iframe本身已支持记住滚动位置的话,那么这个case就可以快乐地结束了 [图片] [代码]<body> <iframe id="js_iframe1" scrolling="yes" src="article1.html"></iframe> <iframe id="js_iframe2" scrolling="yes" src="article2.html" style="display: none;"></iframe> </body> [代码] 可以扫这个二维码看真机表现: [图片] 结果当然是不符合预期的[图片](不然这篇文章也不用写了) 不过iOS和Android的表现并不一致,具体差异看下表: iOS Android 滚动后切换另一个iframe时,滚动位置和切换前一致 滚动后切换另一个iframe时,滚动位置会重置到顶部(即scrollTop=0) 手动修改scrollTop值 理所当然的,聪明的我立马想到一个“完美”的方案:切换时先记住当前iframe的scrollTop,然后再取出另一个iframe的scrollTop(如果有的话)并赋值。 [代码]// 获取iframe的scrollTop const getIframeScrollTop = iframe => iframe.contentWindow.pageYOffset || iframe.contentDocument.documentElement.scrollTop || iframe.contentDocument.body.scrollTop || 0; // 设置iframe的scrollTop const setIframeScrollTop = (iframe, val) => { iframe.contentDocument.documentElement.scrollTop = val; iframe.contentDocument.body.scrollTop = val; }; [代码] 其它代码由于过于简单就不贴了[图片] 可以扫这个二维码看真机表现: [图片] 结果在Android上表现符合预期,但是在iOS上无效,而且取出来的scrollTop恒等于0[图片] iOS Android 滚动后切换另一个iframe时,滚动位置和切换前一致,scrollTop=0 符合预期 怀疑iOS是否共用同一个滚动条 人生遇到瓶颈后就开始怀疑世间万物[图片]于是我开始怀疑iOS的iframe是不是共用了同一个滚动条,不然滚动位置怎么会保持一致? 抱着死马当活马医的心态,我做了这样一个测试,给两个iframe以及page绑定了scroll事件,在事件callback里分别打印’iframe1’、‘iframe2’和’page’。 可以扫这个二维码看真机表现: [图片] 实验结果使我更确信iOS是共用了同一个滚动条 [图片] iOS Android 无论在哪里滚动,都打印’page’ 打印结果符合预期 在iOS中改用page滚动条的scrollTop 判一下ua对iOS做特殊处理,如果是iOS,就对page做scrollTop取值/赋值操作[图片] [代码]const isIOS = /(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent); // 获取iframe的scrollTop(升级版) const getIframeScrollTop = iframe => { if (isIOS) { // iOS下所有iframe共用同一个滚动条 return window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; } // 其余的有各自的滚动条 return iframe.contentWindow.pageYOffset || iframe.contentDocument.documentElement.scrollTop || iframe.contentDocument.body.scrollTop || 0; } // 设置iframe的scrollTop(升级版) const setIframeScrollTop = (iframe, val) => { if (isIOS) { // iOS下所有iframe共用同一个滚动条 document.documentElement.scrollTop = val; document.body.scrollTop = val; } else { // 其余的有各自的滚动条 iframe.contentDocument.documentElement.scrollTop = val; iframe.contentDocument.body.scrollTop = val; } } [代码] 可以扫这个二维码看真机表现: [图片] iOS Android 符合预期 符合预期 幸不辱命,终于可以上线了,哈哈 [图片] 需求上线后重新整理了下思路 由于那天被这个问题折腾了很久,大脑非常疲惫,产品又在催上线,看到问题解决后就立马上线然后下班回家了。隔天回来后,重新整理在网上搜索到关于iOS iframe的文章,又有了新的发现: iOS iframe之所以会和page共用同一个滚动条,是因为overflow: scroll和height(css属性)对iOS iframe不起作用(由于当时需求内容是iframe占全屏,即height: 100vh,所以一直没发现这个问题),所以在iOS里,iframe没有命中overflow逻辑,内容完整地呈现出来,因而和page使用同一个滚动条。 那么,如何使overflow: scroll生效? 其实很简单 [图片] iframe不能用overflow: scroll,那我就让它“爸爸”滚:用div将iframe包裹起来,给这个div加上overflow: scroll和height(css属性)。 不过这时候你会发现当你滚动iframe时,page会动了起来,而iframe毫无反应,就好像“点透”一样。 不怕,贴心的谷歌爸爸给了解决方案:再给这个div加上-webkit-overflow-scrolling: touch,如此一来,iOS iframe的overflow之力终于被解放出来了。 [代码]<div id="js_iframe_wrap"> <iframe id="js_iframe" scrolling="yes" src="article1.html"></iframe> </div> [代码] [代码]#js_iframe_wrap { -webkit-overflow-scrolling: touch; overflow-y: scroll; height: 30vh; } [代码] 可以扫这个二维码看真机表现: [图片] 在这个demo里我将iframe fixed在页面中部,然后将page的高度设置成300vh,最后在页面上放了一个按钮,点击按钮会打印page、iframe以及iframe wrap的scrollTop值。 [代码]const qs = (selector, el) => (el || document).querySelector(selector); const getDocumentScrollTop = () => window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; const getIframeScrollTop = iframe => iframe.contentWindow.pageYOffset || iframe.contentDocument.documentElement.scrollTop || iframe.contentDocument.body.scrollTop || 0; $('#js_log').click(() => { console.log(`document scrollTop: ${getDocumentScrollTop()}`); console.log(`iframe scrollTop: ${getIframeScrollTop(qs('#js_iframe'))}`); console.log(`iframe wrap scrollTop: ${qs('#js_iframe_wrap').scrollTop}`); }); [代码] 可以看到,iframe已经不再和page共用同一个滚动条了,而且iframe的滚动值等于iframe wrap的scrollTop值。 写在后面 最后我也没把产品的需求改成-webkit-overflow-scrolling: touch方案,不是方案有坑,而是我懒得改了[图片]
2019-04-02 - 分享自媒体运营文章CMS类小程序(阅读分享领红包)
分享微信小程序代码 如果有运营意向的欢迎联系我 资讯文章类小程序 玩法:阅读、点赞文章获得积分(组队满员后当天任务积分翻倍),积分用于在积分商城兑换现金会员等礼品。 获得礼品需要添加微信客服为好友,发送兑换码完成。 通过完成任务获得积分,积分在商城兑换成卷,添加客服微信好友后凭卷密令提现。 日常活动: 签到 10积分 组队 满员后日常奖励翻倍 推荐新用户 10积分/1人(每日最多可推荐100个) 推荐的新用户每日签到 3积分/1人(每日最多获得300次) 日常任务: 阅读文章(要拉到底部) 10篇 每篇1积分,共可获得10积分 点赞文章 5篇 每篇2积分,共可获得10积分 预览地址: [图片] 使用了以下开源项目进行开发 wepy colorUI wemark 其它声明 本项目供个人学习、参考,商用需获得本人授权(限免)。 开源仓库地址: https://github.com/yizenghui/wecont
2020-06-13 - 自适应 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 - 富文本组件体验小程序
简介 上周发布的 新富文本显示组件 收获了许多关注,为方便大家了解和体验,[代码]demo[代码] 小程序上线啦 [图片] 大家可以在这里查看介绍和示例或者进行自定义的测试,发现任何问题都欢迎反馈哦 立即体验 [图片] GitHub链接 Github链接
2020-12-27 - 腾讯课堂小程序详情页开发总结
状态管理 一开始为了借鉴和复用课堂H5详情页的状态管理,引入 redux ,但由于 reducer 总是返回一个新的更新后的对象,这意味着每次 setData 时会传递全量的数据,而在小程序双线程界面渲染的数据通信模型下,传输数据量与性能正相关,因此对于数据量比较大的详情页来说,每次 action 操作都比较耗性能,体验不好。 于是改用腾讯开源的小程序状态管理方案 westore, 它利用小程序 setData 函数支持以数据路径的形式传递数据的特点,通过 update 函数先进行 diff 得到最小更新的数据路径集合,然后再调用 setData 函数传递变化的数据以达到更优的性能。 可是 westore 是基于页面路径来同步数据的,如果同时存在两个相同路径的页面,则只有最新的页面会更新;例如当前页面 A (pages/course?cid=A)打开相同路径的页面 B (pages/course?cid=B)时,由于 store 数据是共享的,这时页面 B 持有页面 A 的数据,同时页面 A、B 路径(pages/course)相同,此时 westore 已经丢掉页面 A 的引用,当 westore 更新数据时只会影响到页面 B ,页面 B 返回页面 A 后,已经无法再更新页面 A 了。 对于这个问题,只要增加一个栈来记录页面路径实例,新开页面时,重置 westore 数据,页面返回时,将旧页面实例的数据同步到 westore 即可。 [图片] 除此之外,H5详情页中很多复合的状态逻辑都放在嵌套较深的自定义组件中,可在小程序环境下就有点力不从心了,所以必须要将这部分常变状态和遍历逻辑提前计算,以便 westore diff 局部更新。 富文本 [图片] 课堂详情页中需要展示由富文本编辑器 CKEditor 生成的课程详情,里面可能包含视频,但小程序提供的 rich-text 组件无法支持 video 标签,因此用到 wxParse 来将 HTML 文本解析成 JSON 树,然后通过 view + css 来模拟 HTML 元素进行渲染。 可是 wxParse 已经很久没有更新了,在使用过程中发现它有很多问题和局限性,以下是踩坑改造优化经验: 缺少解码、解析和渲染完成等钩子:由于后台 CGI 返回的 HTML 文本存在二次编码的情况,只经过 wxParse 的一次解码后仍有部分字串没有被正确解析,同时针对某些解析后的 HTML 标签需要扩展其属性等等。 因此只能修改源码增加 beforeDiscode、afterDiscode、parsedStartTag、parsedEndTag、parsed 和 complete 等钩子来提高其灵活性。 含有较多复杂属性的 HTML 标签无法解析出来:主要是 wxParse 中 startTag 的正则表达式不够全面导致的。 [图片] 上图无法解析出第一个 p 标签。 修改一下 startTag 的正则表达式即可。 [图片] 12个相同 template:wxParse 定义了 wxParse0 到 wxParse11 共 12 个 template,这 12 个 template 除了子结构调用不同的 wxParseXX template 之外其余代码都是一样,究其原因是因为小程序 template 不能递归引用,当然这种变通的处理方式有个局限性,就是它处理不了超过 12 层的结构,超过以后就解析不了,再加上小程序的机制,这样是不会报错的,导致查 bug 很困难。要解决这个问题,除了官方支持 template 递归,可以将 wxParse 改为自定义组件(暂未尝试),或者尽可能的合并 HTML 结构。例如 [图片] wxParse 解析渲染后的结果 [图片] 这里可以发现每个 wxParse-inline 元素的样式完全可以合并,同时形如 wxParse-s 等元素是通过 css 来模拟 HTML 元素的,因此对于这样嵌套的行内元素,可以进行合并 [图片] a 标签作为块元素:由于 a 标签允许包裹其中没有交互内容的块元素,wxParse 把 a 标签视为块级元素,导致解析 a 时将其前一个行内元素提前闭合了,造成显示错误。解决办法是将 a 标签从块级元素中剔除。 不支持腾讯视频 vid:小程序 video 仅支持视频地址和云文件 ID,但课程详情会包含腾讯视频,而腾讯视频播放路径需要通过腾讯视频 SDK 将视频 vid 转换出来,由于已经引入腾讯视频组件,VID 转换这一步可以省略交给腾讯视频组件,只需要将 wxParse template 中 video 标签改为 txv-video,同时在 wxParse 解析出 video 数据时计算出 authExt ,连同 vid 等必要字段一并提供给 txv-video 即可播放视频。考虑到课程详情中的视频播放频次不高,没必要详情展示时就生成腾讯视频组件,因此使用封面 + 播放按钮来替代,等待用户点击封面时才生成。 wxParse 样式污染全局:定义了 view 样式,但没有限定在 .wxParse 作用域下生效,导致影响了页面全局。 标签内文本含有 < 则解析结束标签有误 setData含有较多与界面渲染无关的数据 … WXML WXML(WeiXin Markup Language)是小程序视图层的一套标签语言,它与 Vue 的模板语法很相似,但在实际开发过程中经常会遇到一些问题与限制。 数据绑定中的数据处理 在 WXML 中,数据绑定只支持简单的 js 表达式,不能调用方法。例如保留数据的小数点后两位 [代码]<view>{{num.toFixed(2)}}</view> [代码] 这种写法是不会生效的,为了弥补 WXML 中数据处理的短板,小程序提供了 WXS(WeiXin Script)脚本,可以这么做 [代码]<view>{{tools2.toFixed(num, 2)}}</view> <wxs module="tools2"> function toFixed(num, len) { return num.toFixed(len); } module.exports = { toFixed: toFixed } </wxs> [代码] 但要注意的是 wxs 与 javascript 是不同的语言,有自己的语法,并不和 javascript 一致,更不能使用 es6 语法。 wxs 的运行环境和其他 javascript 代码是隔离的,wxs 中不能调用其他 javascript 文件中定义的函数,也不能调用小程序提供的API。 wxs 函数不能作为组件的事件回调。 wxs 目前共有以下几种数据类型:number,string,boolean,object,function,array,date,regexp template 的 data 传参 如果只看 官方文档 template 说明,你可能不知道 template 的 data 传参有三种方式: 格式一:data="{{ …value1,…value2,… }}",value 前面的 [代码]...[代码] 是扩展运算符。 格式二:data="{{ value1,value2,… }}"。 格式三:data="{{ key1: value1,key2: value2,… }}"。 value 可以是 boolean、number、string、null、object、array。 例如 [代码]value = { a: 1, b: 2, c: 3}[代码],那么在 template 中的使用如下: [代码]<!-- 格式一 --> <template name="example1"> <view>{{a}}: {{b}}: {{c}}</view> </template> <template is="example1" data="{{...value}}" /> <!-- 格式二 --> <template name="example2"> <view>{{value.a}}: {{value.b}}: {{value.c}}</view> </template> <template is="example2" data="{{value}}" /> <!-- 格式三 --> <template name="example3"> <view>{{k.a}}: {{k.b}}: {{k.c}}</view> </template> <template is="example3" data="{{k:value}}" /> [代码] 如果在列表渲染时,想要将列表的索引 index 在 template 中使用,可以这样做 [代码]<template name="example4"> <view>{{index}}: {{msg}}: {{time}}</view> </template> <template is="example4" wx:for="{{items}}" data="{{index, ...item}}" /> [代码] 除此之外还可以结合 wxs [代码]<template name="example5"> <view>{{msg}}: {{time}}</view> </template> <template is="example5" data="{{...tools.getLast(items)}}" /> <wxs module="tools"> function getLast(items) { return items[items.length - 1]; } module.exports = { getLast: getLast }; </wxs> [代码] 数据缓存与自定义组件和 wx:if 在做页面数据缓存时,由于页面数据字段比较多且嵌套深,有时图方便,我们会省略嵌套深的字段定义同时将缓存赋给 data,然后直接在 wxml 中使用 [图片] 如果 wxml 中刚好使用了 wx:if 和自定义组件,那么在小程序基础库 2.4.0 及以下,从第二次进入该页面时就会报错 [代码]Expect FLOW_CREATE_NODE but get another[代码],对于这个问题,有几种解决办法: _list 列出所有字段定义。 data 中 list 不直接赋值 _list,改在 onLoad 时通过 setData 传递。 wxml 中 wx:if 改为 hidden 处理,或者不适用自定义组件。 上述问题出现的条件比较特殊,很大部分是编码问题,但从小程序基础库 2.4.1 开始就不会出现。对比了不同版本基础库在 onLoad 阶段输出的 data 信息,发现 2.4.1 及以上 data 的初始值不再等于当前缓存的 _list 值。 2.4.0 及以下第一次和第二次进入该页面时的 data 值,第二次进入已有缓存 [图片] 2.4.1 及以上第一次和第二次进入该页面时的 data 值相同 [图片] 其他 template 模板与 component 组件 template 模块与 component 组件是小程序中组件化的方式。二者的区别: template 模块主要是展示,交互需要在使用 template 的页面中定义。 component 组件拥有自己的数据处理与交互逻辑,类似一个 page 页面。 在需要频繁更新的场景下或者在列表中涉及到列表子项独立的操作时,使用自定义组件可以只在组件内部进行更新,即实现页面局部更新,而不受页面其他部分内容的影响。 onPageScroll 与 IntersectionObserver 在做图片懒加载、元素曝光上报和元素吸顶展示时,离不开元素位置与页面滚动位置的判断,与之相关的事件或API有: onPageScroll:page 中监听用户滑动页面的事件。自定义组件无法使用,只能通过传参或事件总线来获取变化状态。 IntersectionObserver:监听某些节点与参照物边界相交状态的对象。参照物可以是指定一个节点或者页面显示区域。 从触发回调频次来看,onPageScroll 远远高于 IntersectionObserver,而且每一次事件回调都是一次视图到逻辑的通信过程。因此应该只在必要的时候才使用 onPageScroll,其他情况使用 IntersectionObserver 替代较好。
2019-05-07