- Taro团队携手云开发搭建电商后台服务
原创: 京东凹凸实验室 本文从Taro及小程序·云开发的简介开始,介绍了如何通过小程序·云开发搭建电商后台服务,最后再深入地阐述购物车页,订单页的小程序·云开发端处理逻辑,由浅入深地剖析了使用小程序·云开发开发电商后台服务的整个过程。 Taro简介 Taro是由凹凸实验室开源、遵循 React 语法规范的多端开发解决方案,截止目前 star 数已经突破16.9k,受到了前端开发者的广泛关注,成为了当前最受欢迎的小程序多端开发框架之一。 Taro 目前已支持微信小程序、H5、RN、支付宝小程序、百度小程序以及字节跳动小程序,持续迭代中的 Taro,也正在努力兼容更多的端,并增加支持一些新特性。 小程序·云开发简介 先看官方文档的说法: 小程序·云开发是微信团队联合腾讯云团队推出的一套小程序开发解决方案。小程序·云开发为开发者提供完整的云端流程,弱化后端和运维概念,开发者无需购买和管理底层计算资源,包括服务器、数据库、静态存储,只需使用平台提供的简易 API 进行核心业务等开发,实现快速上线和迭代,把握业务发展的黄金时期。 其实翻译过来就是,一个在小程序中使用的,不用购买服务器,不用运维的简易后端体系,主要是为了突出快和简便。所以小程序·云开发,就非常适合那些对数据本身弱依赖的,中小型的功能性小程序使用。 小程序·云开发主要有几大部分组成,分别是云控制台、数据库、云函数、云存储。以及分别在小程序端,和云端使用的 js-sdk、admin-sdk。关于这几部分的具体内容,可以在官方文档中查看。 而与传统的电商后端开发相比,小程序·云开发有以下区别: 传统电商后端开发 小程序·云开发 后端代码 自主编写、开发接口 开发接口,云函数部署 服务器 自主购买、部署 无 数据监控 自主搭建 官方提供,控制台查看 调用日志 自主搭建 官方提供,控制台查看 费用 服务器购买成本 无 项目结构 因为该项目使用了小程序·云开发进行后端开发,故项目结构会有些不同。具体结构使用的是 Taro 初始化时提供的云开发模板,大致结构如下: ├── demo 代码目录 | ├── client 小程序代码目录 | ├── … | ├── cloud 小程序·云开发相关代码目录 | ├── functions 云函数相关目录 | ├── shop shop 云函数目录 | ├── index.js 入口函数 | ├── getShop.js getShop.js | ├── package.json | ├── … | ├── project.config.json 小程序配置文件 | ├── tcb.json 小程序·云开发配置文件 └── README.md readme 文件 可以看到目录里主要分了两大块 client和 cloud: client 里和我们平常小程序的开发目录,存放的都是小程序里业务代码。 cloud 里则是放云函数相关的代码,并且是以模板进行分割,每个模块一个云函数。 基于这样的目录结构,小程序·云开发相关的代码与小程序本身的代码进行了有效分隔,极大地方便了项目的管理与开发,同时也有助于云函数的上线部署。 通过小程序通过小程序·云开发搭建电商后台服务 介绍完 Taro 及小程序·云开发,下面便开始讲解如何通过小程序·云开发搭建一个后端服务。 1.后台服务搭建思路 2.数据库建立 3.数据交互 4.【首页】【商详页】后端逻辑处理 5.【购物车】后端逻辑处理 6.【订单页】后端逻辑处理 后台服务搭建思路 首先,我们知道一个最简单的后端程序就是,开启一个 HTTP服务,连接上数据库,然后根据收到的请求进行相关操作,例如数据库的增删查改,返回 HTML,返回接口数据之类,如果要满足外网访问还要部署上线等等。 而用上了小程序·云开发之后,因为云函数这个概念,我们免去了开启服务器和部署的步骤。同时,小程序是天然前后端分离的,也不需要返回HTML。所以在这种情况下,我们所搭建的后台服务最主要为了实现两个部分的内容,分别是数据库的建立和前后端的数据交互。 1.数据库建立 数据库建立,指的是数据集合及一些初始数据的创建。在我们搭建的这个 Demo 里,主要有以下数据集合: [图片] Information - 首页的资讯数据集。主要是以一个资讯为单位的数据集合,一个资讯可能含有商店图片,商店介绍,商品介绍等,主要作导购作用,点击后引导至相关页面。 Shop - 商店页的数据集。以商店为单位,一个商店页面里主要是各种楼层数据。 Commodity - 商品的数据集。显然,一个商品数据自然就是该商品所需要的各种信息。 Cart - 购物车的数据集。以用户为单位存放购物车数据。 Order - 订单的数据集。以用户为单位存放订单数据。 User - 用户信息的数据集。存放用户信息数据。 上面所讲述的 6个数据集,基本就涵盖了一个最简单的电商所需要的各种数据,可以构成一个完整的购物流程。 同时如下图,还可以设置数据集权限。例如将 Information、 Shop、Commodity设置为所有用户可读、仅管理员可写;将 Cart、 Order、 User改成仅创建者及管理员可读写。通过权限限制,增强了数据集的可靠性。 [图片] 2.数据交互 数据集建立起来后,再往里面填充一些假数据,基本的数据就有了,那么在小程序中如何进行数据交互? 如果不是用小程序·云开发,自然是通过request拉取接口数据,进行展示。而在使用了小程序·云开发的情况下,通过官方提供的 sdk,主要有两种办法进行数据拉取: 1.直接在小程序端操作数据库,获取所需数据,并进行增删查改等操作。 2.使用云函数,把数据库的操作放到云端;然后在小程序端调用云函数,达到类似调用接口的效果。 第一种方法其实比较适合一些简单的、对数据要求不高、量也不大的小程序。不然在小程序的代码中混合着数据库操作,实践起来不太优雅,也不利于维护。 这里重点说下第二种方法。上篇文章有提到了云函数的概念,这里再回顾一下。所谓云函数,就是将一个函数放在Node.js(即服务端)环境下运行。因此,我们可以将数据库的操作放到云函数中执行,然后在小程序中调用云函数,达到一种类似调用接口的效果。回顾我们上一章节说到的云函数的目录: ├── demo 代码目录 | ├── cloud 小程序·云开发相关代码目录 | ├── functions 云函数相关目录 | ├── shop shop 云函数目录 | ├── index.js 入口函数 | ├── getShop.js getShop.js | ├── package.json | ├── … | … └── README.md readme 文件 名字叫 shop 的云函数的具体目录在cloud/functions/shop下,可以见到有一个入口文件 index.js,还有其它的子函数。下面看具体代码: [图片] [图片] 在这个例子中,笔者将一个云函数当成一个模块相关函数的入口,根据函数传入的参数来决定调用哪个函数。而被具体调用的函数,执行的是一些数据的操作,然后返回数据。也就是说,在这个 Demo 里一个云函数是一个数据模块的入口,里面引用了许多待被调用的具体函数,视入参而定。 以数据模块为单位分割云函数,是笔者觉得比较好的做法。一来云函数不必分割得太细,毕竟每个云函数都是独立部署的,省去了一些繁琐的操作;二来以数据模块为单位,就有点类似我们传统后端的 MVC 模式,易于开发者无缝接入。当然,这只是其中的一种云函数代码组织方式,并不代表就一定要遵循这样的方式,具体情况具体分析,还是要结合业务的实际情况。 而具体到小程序的调用,就更简单了,只是将请求接口的操作改为调用云函数的操作。比如: [图片] 可以看到,仅仅是将调用 wx.request改为了调用 wx.cloud.callFunction,其它的地方并不需要改变太多。返回的数据也是可以自己定义的,达到了与调用接口相同的效果。 不过云函数有一个缺点,就是每次都要上传部署后才能被小程序端调用,调试起来略显麻烦。一个比较好的调试方法是添加一个测试函数,在本地环境中使用 Node.js 进行测试。 [图片] 2.1【首页】【商详页】后端逻辑处理 经过上一段落的讲解,相信大家对于整个商城后端服务的搭建与处理逻辑已经有了基本了解。下面我们看一下首页和商详页的页面结构。 首页: [图片] 商详页: [图片] 实际上,上一段落中所举例shop云函数,便是处理首页和商详页的后端逻辑。可以看到,其逻辑只是简单的根据id拉取数据并返回,因为整体也并没有过多与用户发生交互的部分,也没有需要后端逻辑处理的部分,总的来说还是比较简单的,在这里便不作过多介绍。 2.2【购物车】后端逻辑处理 购物车页相较于首页和商详页,其逻辑必定是复杂了很多,下面结合页面结构及代码分析一下。 [图片] 上图是商城demo的购物车截图。可以看到在购物车里,小程序·云开发端需要处理的逻辑有商品的选择与反选、商品删除、商品数量的更改、商品型号的更改等等。因此,我们把购物车操作分类,得到如下一个 map: [图片] 然后,在用户执行相应的操作时,我们便会执行到对应的操作函数: [图片] 在这里,每个操作函数的入参都是 oldCartInfo(旧的购物车里的商品)、 skus(需要更改的 skus 数组)。然后返回处理后、最新的 newCartInfo (新的购物车里的商品)。具体的操作函数的逻辑我们便不再阐述了,主要就是对数组进行遍历然后根据相关操作处理数据。 接下来,根据最新 newCartInfo,来得到完整购物车数据,完整的购物车数据结构如下所示: 更新完数据库后,便会返回给前端最新的购物车数据。 总结下来,整个购物车后端逻辑的流程,可以用如下的流程图描述: 如果后续有新的购物车操作需要迭代,或者处理逻辑需要变更,我们也只需要改变小程序·云开发端执行函数 这一部分里面的内容即可。 2.3【订单页】后端逻辑处理 同样的,我们先看一下订单页的结构。 订单详情页: [图片] 订单页这块主要处理的是生成订单的逻辑。每个用户的购物车中,已勾选的商品数据都是存放在数据库中的,所以当用户点击了去结算按钮,触发了结算请求时,后端会直接从用户数据库中的购物车数据,生成一份订单。详细的流程可以用如下的流程图描述: [图片] 下面我们来看具体代码: [图片] 从代码中可以看到,先是遍历当前购物车中的商品,然后把已经勾选的商品存放到 payInfo中。接着根据 payInfo 生成订单数据,同时除移购物车中已被结算的商品,并更新购物车数据库。 整体来说,并没有太复杂的操作,不过需要注意的是,因为存在很多异步的操作,所以会有使用很多 await 命令来进行同步书写。 除了生成订单之外,还有取消订单、删除订单等操作。相较于生成订单,这些就只是读取订单、更改状态而已,便不赘叙。 总结 笔者作为开发者,使用小程序·云开发后,深感其便利性。私以为有以下几点优势: **便捷。**略去了后端部署、运维等步骤,可以快速地构建所需要的后端应用,非常适合灵活轻便的小程序开发。 免费。目前小程序·云开发提供了免费 2GB 的数据库存储和 免费 5 GB 的文件存储,虽然存储量并不是很大,但对于个人开发者来说,这些存储量绰绰有余。 开发简单。小程序·云开发的使用,云函数的开发都是非常简单的,官方提供的API可以让我们便捷地进行操作。只需掌握 JavaScript 和一些异步处理相关的知识,便可以快速上手。 一致性。小程序·云开发是小程序官方推出的一种解决方案,与正常的小程序开发无缝连接,而且不用担心是否会继续维护、升级迭代等的问题。 最后希望这篇文章对于看完的你有所帮助!
2019-04-22 - 腾讯 Omi 团队发布 mps - 原生小程序支持 JSX 和 Less
写在前面 原生小程序插上 JSX 和 Less 的翅膀 mps 是什么?为什么需要 mps?先列举几个现状: 目前小程序开发使用最多的技术依然是原生小程序 原生小程序的 API 在不断完善和进化中 JSX 是表达能力和编程体验最好的 UI 表达式 JSX 可以表达一切想表达的 UI 结构也就能够描述任意 WXML 所以,就有了 mps。 让开发者直接在原生小程序使用 JSX 写 WXML,实时编译,实时预览。 → mps github 地址 [图片] JSX 代替 WXML 书写结构,精简高效 对原生小程序零入侵 支持 JS 和 TS 实时编译,实时预览 输出 WXML 自动美化 支持 Less 输出 WXSS 效果预览 [图片] 立即开始 [代码]$ npm i omi-cli -g $ omi init-mps my-app $ cd my-app $ npm start [代码] 接着把小程序目录设置为 my-app 目录便可以愉快地开始开发调试了! [代码]npx omi-cli init-mps my-app[代码] 也支持(npm v5.2.0+) 生成的目录和官方的模板一致,只不过多了 JSX 文件,只需要修改 JSX 文件就会实时修改 WXML。 也支持 typescript: [代码]$ omi init-mps-ts my-app [代码] 其他命令一样。 [代码]npx omi-cli init-mps-ts my-app[代码] 也支持(npm v5.2.0+) JSX vs WXML 这里是一个真实的案例说明 JSX 的精巧高效的表达能力: 编译前的 JSX: [代码]<view class='pre language-jsx'> <view class='code'> {tks.map(tk => { return tk.type === 'tag' ? <text class={'token ' + tk.type}>{ tk.content.map(stk => { return stk.deep ? stk.content.map(sstk => { return <text class={'token ' + sstk.type}>{sstk.content || sstk}</text> }) : <text class={'token ' + stk.type}>{stk.content || stk}</text> })}</text> : <text class={'token ' + tk.type}>{tk.content || tk}</text> })} </view> </view> [代码] 编译后 WXML: [代码]<view class="pre language-jsx"> <view class="code"> <block wx:for="{{tks}}" wx:for-item="tk" wx:for-index="_anonIdx4"> <block wx:if="{{tk.type === 'tag'}}" ><text class="{{'token ' + tk.type}}" ><block wx:for="{{tk.content}}" wx:for-item="stk" wx:for-index="_anonIdx2" ><block wx:if="{{stk.deep}}" ><text class="{{'token ' + sstk.type}}" wx:for="{{stk.content}}" wx:for-item="sstk" wx:for-index="_anonIdx3" >{{sstk.content || sstk}}</text > </block> <block wx:else ><text class="{{'token ' + stk.type}}" >{{stk.content || stk}}</text > </block> </block> </text> </block> <block wx:else ><text class="{{'token ' + tk.type}}">{{tk.content || tk}}</text> </block> </block> </view> </view> [代码] 老项目使用 mps 拷贝以下文件到小程序根目录: _scripts 目录所有文件 package.json gulpfile.js .jsx 和 .less 文件 设置 project.config.json 里的 packOptions.ignore 忽略以上的文件,具体的配置可以从这里复制过去,然后: [代码]$ npm install $ npm start [代码] mps 约定 公共的 less 文件必须放在 common-less 目录,@import 使用的时候不需要写路径。 推荐搭配 既然用了原生小程序的方案,所有可以轻松使用 mps + omix 搭配一起使用。 欢迎使用腾讯 Omi 团队集合京东 O2Team 智慧联合打造的 mp-jsx 大幅提高开发效率,Have fun! Github → mps
2019-04-02 - 有赞百亿级日志系统架构设计
一、概述 日志是记录系统中各种问题信息的关键,也是一种常见的海量数据。日志平台为集团所有业务系统提供日志采集、消费、分析、存储、索引和查询的一站式日志服务。主要为了解决日志分散不方便查看、日志搜索操作复杂且效率低、业务异常无法及时发现等等问题。 随着有赞业务的发展与增长,每天都会产生百亿级别的日志量(据统计,平均每秒产生 50 万条日志,峰值每秒可达 80 万条)。日志平台也随着业务的不断发展经历了多次改变和升级。本文跟大家分享有赞在当前日志系统的建设、演进以及优化的经历,这里先抛砖引玉,欢迎大家一起交流讨论。 二、原有日志系统 有赞从 16 年就开始构建适用于业务系统的统一日志平台,负责收集所有系统日志和业务日志,转化为流式数据,通过 flume 或者 logstash 上传到日志中心(kafka 集群),然后共 Track、Storm、Spark 及其它系统实时分析处理日志,并将日志持久化存储到 HDFS 供离线数据分析处理,或写入 ElasticSearch 提供数据查询。整体架构如图 2-1 所示。 [图片] 图2-1 原有日志系统架构 随着接入的应用的越来越多,接入的日志量越来越大,逐渐出现一些问题和新的需求,主要在以下几个方面: 业务日志没有统一的规范,业务日志格式各式各样,新应用接入无疑大大的增加了日志的分析、检索成本。 多种数据日志数据采集方式,运维成本较高。 日志平台收集了大量用户日志信息,当时无法直接的看到某个时间段,哪些错误信息较多,增加定位问题的难度。 存储方面: 采用了 Es 默认的管理策略,所有的 index 对应 3*2 shard(3 个 primary,3 个 replica),有部分 index 数量较大,对应单个 shard 对应的数据量就会很大,导致有 hot node,出现很多 bulk request rejected,同时磁盘 IO 集中在少数机器上; 对于 bulk request rejected 的日志没有处理,导致业务日志丢失; 日志默认保留 7 天,对于 ssd 作为存储介质,随着业务增长,存储成本过于高昂; 另外 Elasticsearch 集群也没有做物理隔离,Es 集群 oom 的情况下,使得集群内全部索引都无法正常工作,不能为核心业务运行保驾护航。 三、现有系统演进 日志从产生到检索,主要经历以下几个阶段:采集->传输->缓冲->处理->存储->检索,详细架构如图 3-1 所示 [图片] 图3-1 现有系统架构 3.1日志接入 日志接入目前分为两种方式,SDK 接入和调用 Http Web 服务接入 SDK 接入:日志系统提供了不同语言的 SDK,SDK 会自动将日志的内容按照统一的协议格式封装成最终的消息体,并最后最终通过 TCP 的方式发送到日志转发层(rsyslog-hub); Http Web 服务接入:有些无法使用 SDk 接入日志的业务,可以通过 Http 请求直接发送到日志系统部署的 Web 服务,统一由 web protal 转发到日志缓冲层的 kafka 集群。 3.2日志采集 [图片] 现在有 rsyslog-hub 和 web portal 做为日志传输系统,rsyslog 是一个快速处理收集系统日志的程序,提供了高性能、安全功能和模块化设计。之前系统演进过程中使用过直接在宿主机上部署 flume 的方式,由于 flume 本身是 java 开发的,会比较占用机器资源而统一升级为使用 rsyslog 服务。为了防止本地部署与 kafka 客户端连接数过多,本机上的 rsyslog 接收到数据后,不做过多的处理就直接将数据转发到 rsyslog-hub 集群,通过 LVS 做负载均衡,后端的 rsyslog-hub 会通过解析日志的内容,提取出需要发往后端的 kafka topic。 3.3日志缓冲 Kafka 是一个高性能、高可用、易扩展的分布式日志系统,可以将整个数据处理流程解耦,将 kafka 集群作为日志平台的缓冲层,可以为后面的分布式日志消费服务提供异步解耦、削峰填谷的能力,也同时具备了海量数据堆积、高吞吐读写的特性。 3.4日志切分 日志分析是重中之重,为了能够更加快速、简单、精确地处理数据。日志平台使用 spark streaming 流计算框架消费写入 kafka 的业务日志,Yarn 作为计算资源分配管理的容器,会跟不同业务的日志量级,分配不同的资源处理不同日志模型。 整个 spark 任务正式运行起来后,单个批次的任务会将拉取的到所有的日志分别异步的写入到 ES 集群。业务接入之前可以在管理台对不同的日志模型设置任意的过滤匹配的告警规则,spark 任务每个 excutor 会在本地内存里保存一份这样的规则,在规则设定的时间内,计数达到告警规则所配置的阈值后,通过指定的渠道给指定用户发送告警,以便及时发现问题。当流量突然增加,es 会有 bulk request rejected 的日志会重新写入 kakfa,等待补偿。 3.5日志存储 原先所有的日志都会写到 SSD 盘的 ES 集群,logIndex 直接对应 ES 里面的索引结构,随着业务增长,为了解决 Es 磁盘使用率单机最高达到 70%~80% 的问题,现有系统采用 Hbase 存储原始日志数据和 ElasticSearch 索引内容相结合的方式,完成存储和索引; Index 按天的维度创建,提前创建index会根据历史数据量,决定创建明日 index 对应的 shard 数量,也防止集中创建导致数据无法写入。现在日志系统只存近 7 天的业务日志,如果配置更久的保存时间的,会存到归档日志中; 对于存储来说,Hbase、Es 都是分布式系统,可以做到线性扩展。 四、多租户 随着日志系统不断发展,全网日志的 QPS 越来越大,并且部分用户对日志的实时性、准确性、分词、查询等需求越来越多样。为了满足这部分用户的需求,日志系统支持多租户的的功能,根据用户的需求,分配到不同的租户中,以避免相互影响。 [图片] 针对单个租户的架构如下: [图片] SDK:可以根据需求定制,或者采用天网的 TrackAppender 或 SkynetClient; Kafka 集群:可以共用,也可以使用指定 Kafka 集群; Spark 集群:目前的 Spark 集群是在 yarn 集群上,资源是隔离的,一般情况下不需要特地做隔离; 存储:包含 ES 和 Hbase,可以根据需要共用或单独部署 ES 和 Hbase。 五、现有问题和未来规划 目前,有赞日志系统作为集成在天网里的功能模块,提供简单易用的搜索方式,包括时间范围查询、字段过滤、NOT/AND/OR、模糊匹配等方式,并能对查询字段高亮显示,定位日志上下文,基本能满足大部分现有日志检索的场景,但是日志系统还存在很多不足的地方,主要有: 缺乏部分链路监控:日志从产生到可以检索,经过多级模块,现在采集,日志缓冲层还未串联,无法对丢失情况进行精准监控,并及时推送告警。 现在一个日志模型对应一个 kafka topic,topic 默认分配三个 partition,由于日志模型写入日志量上存在差异,导致有的 topic 负载很高,有的 topic 造成一定的资源浪费,且不便于资源动态伸缩。topic 数量过多,导致partition 数量过多,对 kafka 也造成了一定资源浪费,也会增加延迟和 Broker 宕机恢复时间。 目前 Elasticsearch 中文分词我们采用 ikmaxword,分词目标是中文,会将文本做最细粒度的拆分,但是日志大部分都是英文,分词效果并不是很好。 上述的不足之处也是我们以后努力改进的地方,除此之外,对于日志更深层次的价值挖掘也是我们探索的方向,从而为业务的正常运行保驾护航。 文末福利 4月27日(周六)下午13:30 有赞技术中间件团队联合Elastic中文社区 围绕Elastic的开源产品及周边技术 在杭州举办一场线下技术交流活动 本次活动免费开放,限额200名 扫描下图二维码,回复“报名”即可参加 [图片] 欢迎参加,咱们一起聊聊~
2019-04-15 - 小程序转支付宝小程序工具:wx2my
背景目前市面上有很多微信小程序,同时开发者开发完微信小程序后,希望可以同时发布到支付宝小程序平台上,可惜微信小程序并不能直接发布到支付宝平台上,两个平台小程序不兼容。因此开发者需要对微信小程序代码进行修改,调整成支付宝小程序代码。 庆幸的事两种小程序代码有很多相似之处,手动修改比较繁琐,因此小程序助手孕育而生。达到自动把微信小程序代码转换成支付宝小程序。不过由于两种小程序功能和api等的不一致,转换后生成的支付宝小程序并不能直接运行起来,还需要进行代码检查,手动的修改无法转换的部分。 地址 vscode插件: wx2my(微信小程序转支付宝小程序) cli命令工具: wx2my npm地址 使用文档: wx2my 语雀地址 目标 快速转换微信小程序为支付宝小程序,达到快速转换,降低转换成本,这样可以早点下班。 视频教程[视频] 能力 文件名转换app文件名转换: 微信小程序 --> wx2my --> 支付宝小程序 app.json app.json app.js app.js app.wxss app.acss page页面、自定义组件文件名转换: 微信小程序 --> wx2my --> 微信小程序 index.json index.json index.js index.js index.wxml index.axml index.wxss index.acss 其他类型文件名转换: 微信小程序 --> wx2my --> 支付宝小程序 parse.wxs parse.sjs 其他类型文件(图片、视频等) 直接复制,不转换 文件内容转换app.json 转换 app.json文件为整个小程序配置文件,不过微信小程序app.json和支付宝小程序在app.json配置文件支持的能力不完全一致,部分一致的但名称不一致的配置,转换工具会分析并转换出来。 转换方式: navigationBarTitleText --> defaultTitle enablePullDownRefresh --> pullRefresh navigationBarBackgroundColor --> titleBarColor ...等 其中微信小程序支持,支付宝小程序不支持的,需要开发者自己手动修改,如:networkTimeout、functionalPages、workers等 全局组件转换 微信小程序支持全局组件,即在app.json中添加usingComponents字段,这样在小程序内的页面或自定义组件中可以直接使用全局组件而无需再声明。 转换方式: 转换工具会分析小程序中所有页面和组件,找到那些使用了全局组件的页面和组件,并把全局组件声明在页面或组件的json文件中,当做普通组件引用和使用。同时把全局组件的声明删除。 wxml文件转换 转换逻辑是以wx:xxx开头的,替换为a:xxx方式。 a. 事件相关的转换,微信中 bindeventname 或 bind:eventname 转换为 onEventname, 如下: 转换前: <page bindtap="showName" bind:input = "actionName" catchchange="catchchange"bindtouchend="onTouchEnd"></page> 转换后: <page onTap="showName" onInput = "actionName" catchChange="catchchange" onTouchEnd="onTouchEnd"></page> b: 循环语句转换, 如下: 转换前: <view wx:for="{{array}}" wx:for-index="idx" wx:for-item="itemName" wx:key="unique">{{idx}}: {{itemName.message}}</view> 转换后: <view a:for="{{array}}" a:for-index="idx" a:for-item="itemName" a:key="unique"> {{idx}}: {{itemName.message}}</view> c: wxs代码转换,微信小程序中的wxs功能对应支付宝小程序中的sjs功能,微信wxml中支持引用外部wxs文件和内联wxs代码,支付宝中只支持引用外部文件方式使用sjs,不支持内联sjs代码。 转换方式:转换工具分享所有wxml文件,找到wxs内联代码,提取wxs的内联代码,生成sjs文件,并使用外部引用的方式引入sjs文件,如下: 转换前: <wxs src="../wxs/utils.wxs" module="utils" /><wxs src="../wxs/utils.wxs" module="utils"> </wxs><wxs module="parse"> module.exports.getMax = getMax;</wxs> 转换后: <import-sjs from="../wxs/utils.sjs" name="utils" /><import-sjs from="../wxs/utils.sjs" name="utils"/><import-sjs from="./parse.sjs" name="sjsTest" />并在同级目录下创建了 [代码]parse.sjs[代码] 文件,并转换wxs的CommonJS为ESM parse.sjs文件内容: export default { getMax }; d: 无法替换完成的,在转换后的支付宝小程序的代码中,插入注释代码,提醒开发者并需要开发者手动检查修改。如下: 转换前: <cover-image class="img" src="/path/to/icon_play" bindload="bindload" binderror="binderror" aria-role="xxx"aria-label="xxx"/> 转换后: <cover-image class="img" src="/path/to/icon_play" bindload="bindload" binderror="binderror" aria-role="xxx"aria-label="xxx"/><!-- WX2MY: 属性bindload、binderror、aria-role、aria-label不被支持,请调整 --> 出现这种情况,开发者可以手动的搜索 [代码]WX2MY:[代码] 关键字,查找需要修改的代码 js文件转换 转换工具对api相关的调用转换使用了桥接文件 [代码]wx2my.js[代码] ,在所有js文件顶部引入wx2my.js文件,对api的调用,使用桥接函数,桥接函数对api参数不一致的地方在函数内部进行处理,如下: 转换前: wx.request(opts) 转换后: wx2my.request(opts) [代码]wx[代码] 转换为 [代码]wx2my[代码] ,其中wx2my为前进函数对外的方法 桥接函数中 [代码]request[代码] 的方法如下: [图片]
2019-04-17 - 如何通过小程序实现跨平台开发
背景 前段时间要做一系列的测试工具,需要在多平台:iOS、android、H5、公众号、小程序都实现。功能基本一样,就是在支付步骤需要区分平台,用对应的支付方式支付。本文讨论如何用一套小程序代码实现上述5个平台的开发。 效果图(左边为小程序,右边为浏览器): [图片] omi-mp的介绍 omi-mp是腾讯前端框架omi的一个工具集,其目的是在于将小程序代码转成H5/Web,具体可以参见omi-mp的介绍Github。 omi-mp的转换并不是完全兼容小程序所有特性的,只支持了一小部分小程序API,并且存在了一些兼容特性,因此就需要开发者在开发小程序代码时,更多的以开发H5/Web的思路开发。 思路图和实现步骤 [图片] 先实现小程序代码 1.初始化omi-mp目录工程: [代码]npm i omi-cli -g omi init-mp {工程名称} cd {工程名称} npm install [代码] 2.把小程序项目拷贝到[代码]src-mp[代码]目录。 3.建议边实现小程序的过程中,不断的检验生成的H5的正确性,避免在最后阶段检验,否则如果出现问题,将不好定位,本地运行H5命令: [代码]npm start //开发 [代码] 再将小程序打包成H5/Web 打包H5/Web命令: [代码]npm run build //发布 [代码] 发布需要确认域名,修改[代码]package.json[代码]文件,修改[代码]"build": "PUBLIC_URL={发布域名} node scripts/build.js"[代码] 如果存在部分js文件丢失,可以尝试执行 [代码]gulp copyThen [代码] 公众号直接加载H5 公众号本质上也属于H5。 iOS/android App通过内嵌网页加载H5 iOS通过MKWebView加载H5。 android通过WebView加载H5。 H5和原生App的交互部分可以通过JSBridge或者URL拦截实现 小程序代码如何区分平台 综上所述,除去部分iOS/android的原生代码外,基本所有的逻辑都是放在小程序里,按不同平台实现不同逻辑,小程序可以通过UserAgent以及Dom区分: 如果Dom树不存在[代码]window[代码]或者[代码]document[代码],为小程序平台; iOS/android App内嵌网页可以自定义特殊的UserAgent,小程序代码可以通过此来区分iOS/android App平台; 微信App内嵌浏览器的UserAgent会带入[代码]MicroMessenger/[代码]关键字,可以按此区分公众号平台; 其余为H5/Web平台; [代码]if (typeof window == 'undefined') { // 小程序 } else { if (navigator.userAgent.userAgent.indexOf('ios-app') != -1 || navigator.userAgent.userAgent.indexOf('android-app') != -1) { // iOS/android } else if (this.globalData.userAgent.indexOf('MicroMessenger/') != -1) { // 公众号 } else { // Web } } [代码] iOS/android原生代码如何桥接 1.iOS/android需要先设置特殊UserAgent让小程序代码知道是iOS/android平台 iOS: [代码]NSDictionary *dictionary = [NSDictionary dictionaryWithObjectsAndKeys:@"ios-app", @"UserAgent", nil]; [[NSUserDefaults standardUserDefaults] registerDefaults:dictionary]; [代码] android: [代码]webView.getSettings().setUserAgentString("android-app"); [代码] 2.以URL拦截为例,小程序代码在当处于iOS/android平台的情况下,可以发送特殊URL,让iOS/android原生代码处理: 小程序: [代码]if (navigator.userAgent.userAgent.indexOf('ios-app') != -1 || navigator.userAgent.userAgent.indexOf('android-app') != -1) { let url = 'app://pay?' + query; window.location.href = url; } [代码] 3.iOS/android拦截URL特殊处理 iOS: [代码]- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { NSURL * url = navigationAction.request.URL; if ([url.absoluteString hasPrefix:@"app://pay"]) { // do something... decisionHandler(WKNavigationActionPolicyCancel); return; } decisionHandler(WKNavigationActionPolicyAllow); } [代码] android: [代码]webView.setWebViewClient(new WebViewClient(){ public boolean shouldOverrideUrlLoading(WebView view, String url) { if (url.startsWith("app://pay")) { // do something... return true; } return false; } }); [代码] omi-mp的部分缺陷和一些踩过的坑 1.目前omi-mp只支持部分小程序部分API: [代码]- wx.request - wx.navigateTo - wx.navigateBack - wx.getSystemInfo - wx.getSystemInfoSync - wx.setNavigationBarTitle - this.setData - this.triggerEvent [代码] 如果用了其他的API,那么将会在输出H5报错。 2.支持组件,但是不支持组件的函数直接调用,替代方案可以用mitt,在page和component之间用mitt消息传递。 3.如果需要同时输出H5和Web,那么需要同时绑定[代码]click[代码]和[代码]tap[代码]事件(小程序只能绑定tap,Web只能绑定click,H5两者都可以),但是同时绑定又将会造成在H5的情况下,[代码]click[代码]和[代码]tap[代码]都会回调,导致两次调用。解决办法是可以在[代码]click[代码]和[代码]tap[代码]的地方同时加上判断,避免两次调用: [代码]handleTap(e) { if (!((typeof window == 'undefined') && e.type === "tap")) { return; } else if (!((typeof window != 'undefined') && e.type === "click")) { return; } // do something... } [代码] 4.wxml不支持Object字段的遍历处理。 5.如果编译不过,那么确认下是不是wxml中存在了一些特殊关键字,与omi的重了,导致失败。 6.即便在H5的场景下也无法使用[代码]document.getElementById()[代码]。但是有替代方案,只是比较麻烦。 7.还有其它缺陷,有些忘了,待补充。 结语 omi-mp是一个不错的工具,在小程序不断变大变强的今天,能做到一套小程序代码,多端运行,降低开发成本。这里尤其感谢dntzhang的大力支持,希望omi越做越好。
2019-04-12 - 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