- 【集合】花了 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 - 基于微信小程序.云开发,互联网+餐饮解决方案
小程序码农们的福利来了! 第一次接触微信小程序云开发,就迷恋上她了。首先,她提供免费的基础版,降低用户的使用成本,无需考虑服务器的问题。其次,她融入了微信的生态,可以利用很多的资源。如果能够把云开发技术潜能激发,可以产生巨大的效益。我相信云开发在未来一两年会把大量SaaS平台干掉。在如此的诱惑下,我就产生了创业的念头,云开发+餐饮的解决方案。一方面通过成为微信支付服务商,发展特约商户,获得支付流水;另一方面为餐饮客户提供小程序定制服务。然而,梦想很美好,现实很残酷。新冠肺炎的爆发,餐饮行业受到重创,倒闭的倒闭,停业的停业。因此,我们的业务很难开展,粮草不足,创业失败! 经过一段时间的思想斗争,决定把项目开源,让更多的码农兄弟感受云开发的强大,同时避免采坑。 项目地址:https://git.weixin.qq.com/bmwkj/waiter-dev https://github.com/kem709394/waiter-dev 产品介绍:http://www.bmwkj.ltd [视频] [图片][图片][图片] 为特餐饮助手 基于微信小程序.云开发(serverless)技术 结合物联网云打印服务,实现低成本,高效能的互联网+餐饮解决方案 无需租用服务器,开通微信提供的云开发基础套餐(免费),即可搭建整套系统方案 为特餐饮助手的特点:简单、易用、高效、稳定、安全,最重要的是低成本。 功能特色: 点餐:扫描点餐、预定点餐、协助点餐、呼叫服务 外卖:配送外卖、自提外卖 订桌:预定餐桌 排队:排队取号 配送:配送跟踪 后台功能: 服务中心,提供服务员常用的功能,如服务员帮助客人下单,菜品状态更新,查看餐桌状况,自定义菜品价格(当菜品价格为时价,通过扫码获取价格),订单处理(接单、制作、取消、退款、完成等功能),排队取号和餐桌呼叫的处理,配送跟踪辅助功能(扫码取件,完成订单,退回订单,订阅派单通知等) 用户管理,管理微信用户的信息 职员管理,管理商户的职员,根据职务分配相应的操作权限 厨房管理,分配厨房打印机,如果厨房用到打印机,需要在此功能进入绑定 餐桌管理,管理餐桌的信息,可以在线生成点餐码 栏目管理,管理菜品的栏目,对菜品进行分类 菜品管理,管理所有的菜品,包括普通菜品、时价菜品(没有固定价格,通过扫码获取价格)、套餐菜品(由普通菜品进行组合),普通菜品和时价菜品可以设置出菜厨房,普通菜品可以设置多种规格和多种可选属性。 订单管理,管理所有的订单,包括点餐、外卖、订桌 公告管理,管理小程序的公告内容 反馈管理,管理用户提交的反馈内容 打印管理,管理云打印,绑定打印机的设备码,可以测试打印机 配送记录,管理配送记录,跟踪配送信息 支付记录,管理支付记录 统计报表,可统计内容包括订单数量,菜品销量,配送数量,有年月日三个维度选择 系统设置,对系统的参数进行设置 麻雀虽小五脏俱全,本解决方案能够满足大部分餐饮行业商户的需求。 由于微信提供的免费资源有限,本应用通过了长时间优化和验证,满足每天300单一下的商户免费使用。 serverless毕竟是新技术,使用过程中肯定会遇到很多坑,但总有解决的办法。 技术支持 [图片]
2020-07-20 - 响应式原理解析
Vue响应式 前言 数据响应式系统是Vue最显著的一个特性,我们通过Vue官方文档回顾一下。 数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接,不过理解其工作原理同样重要,这样你可以避开一些常见的问题。 现在是时候深入一下了! 本文针对响应式系统的原理进行一个详细介绍。 响应式是什么 我们先来看一个例子 [代码]<div id="app"> <p>{{color}}</p> <button @click="changeColor">change color!</button> </div> new Vue({ el: '#app', data() { color: 'blue' }, methods: { changeColor() { this.color = 'yellow'; } } }) [代码] 当我们点击按钮的时候,视图的p标签文本就会从 [代码]blue[代码]改变成[代码]yellow[代码]。 Vue要完成这次更新,其实需要做两件事情: 监听数据[代码]color[代码]的变化。 当数据[代码]color[代码]更新变化时,自动通知依赖该数据的视图。 换成专业那么一点点点的名词就是利用[代码]数据劫持/数据代理[代码]去进行[代码]依赖收集[代码]、[代码]发布订阅模式[代码]。 我们只需要记住一句话:在getter中收集依赖,在setter中触发依赖 如何追踪侦测数据的变化 首先有个问题,如何侦测一个对象的变化? 目前来说,侦测对象变化有两种方法。大家都知道的! Object.defineProperty vue 2.x就是使用[代码]Object.defineProperty[代码]来数据响应式系统的。但是用此方法来来侦测变化会有很多缺陷。例如: Object.defineProperty无法监控到数组下标的变化,导致通过数组下标添加元素,不能实时响应; Object.defineProperty只能劫持对象的属性,从而需要对每个对象,每个属性进行遍历,如果,属性值是对象,还需要深度遍历。 … 本文也是利用Object.defineProperty来介绍响应式系统。 Proxy vue3就是通过proxy实现响应式系统的。而且在国庆期间已经发布pre-alpha版本。 相比旧的[代码]Object.defineProperty[代码], [代码]proxy[代码]可以代理整个对象,并且提供了多个[代码]traps[代码],可以实现诸多功能。此外,Proxy支持代理数组的变化等等 当然proxy也有一个致命的缺点,就是无法通过[代码]polyfill[代码]模拟,兼容性较差。 依赖收集的重要角色 Dep Watcher [代码]Dep[代码]、[代码]Watcher[代码]是数据响应式中两个比较重要的角色。 收集依赖的地方 Dep 因为在视图模板上可能有多处地方都引用同一个数据,所以要有一个地方去存放数据的依赖,这个地方就是Dep。 Dep主要维护一个依赖的数组,当我们利用render函数生成VNode的时候,会触发数据的getter,然后则会把依赖push到Dep的依赖数组中。 依赖是Watcher! 我们可以把[代码]Watcher[代码]理解成一个中介的角色,数据发生变化时,会触发数据的setter,然后通过遍历Dep中的依赖Watcher,然后Watcher通知额外的操作,额外的操作可能是更新视图、更新computed、更新watch等等… 原理实现 还是那句话:在getter中收集依赖,在setter中触发依赖。 下面我们看看代码: 代码有点长,下面会有步骤来讲解一次。 [代码]/* * 劫持数据的getter、setter */ function defineReactive(data, key, val) { // 1-1:把color数据变成响应式 const dep = new Dep(); Object.defineProperty(data, key, { enumerable: true, configurable: true, get() { // 3. 因为模板编译watcher访问到了color,从而触发get方法,触发了收集依赖的方法 dep.depend(); return val; }, set(newVal) { if (val === newVal) { return; } val = newVal; // 4-1. 假设我们通过 `this.color = 'yellow';`去更改`color`的值,就会触发set方法。 dep.notify(); } }); } /* * dep类,收集依赖,和触发依赖 */ class Dep { constructor() { this.subs = []; // 收集依赖的数组 } // 收集依赖 depend() { // 3-1. 通过外部的变量来添加到color的依赖中. if (window.target && !this.subs.includes(window.target)) { this.subs.push(window.target); } } // 通知依赖更新 notify() { // 4-2. 遍历 this.subs.forEach(watcher => { watcher.update(); }); } } /** * 数据与外部的中介 */ class Watcher { constructor(expr, cb) { this.cb = cb; this.expr = expr; // 2-1. 这里触发了get方法 this.value = this.get(); } get() { // 2-2. 这里把自己(watcher)赋值给了外部其中的一个变量 window.target = this; // 2-3. data[this.expr]触发了color的get const value = data[this.expr]; window.target = undefined; return value; } update() { this.value = this.get(); this.cb(this.value); } } [代码] 下面我们来走一次流程。 括号里面的1-1,2-2是对应代码的执行点。 1. 把数据变成响应式 利用[代码]defineReactive[代码]把color数据变成响应式(1-1),执行过这个方法后,我们调用[代码]console.log(this.color)[代码]的时候可以触发get方法。同理当我们[代码]this.color = 'yellow'[代码]。 注意:在[代码]Object.defineProperty[代码]上面初始化一个存放依赖的dep,这里其实是把dep作为数据[代码]color[代码]的一个私有变量,让get和set的方法可以访问到,也是我们经常说的闭包。 2. 编译模板创建watcher 假设我们现在编译模板遇到[代码]{{color}}[代码]。 Vue就会创建一个Watchter,伪代码如下: [代码]new Watcher('color', () => { // 当color发生变化的时候,会触发这里的方法。 }); [代码] 这里高能!! Watcher的构造函数里面调用了[代码]get()[代码]方法(2-2),把自己(watcher)赋值给了一个外部变量。 然后再触发get方法(2-3)。 3. get中收集依赖 因为模板编译watcher访问到了color,从而触发get方法,触发了收集依赖的方法。 进入到[代码]dep.depend[代码]方法中(3-1),这里因为在Watcher中把自己存到了外部变量中,所以在dep.depend方法中可以收集到依赖。 现在,依赖就被收集了。 4. 通过setter触发依赖 假设我们通过 [代码]this.color = 'yellow';[代码]去更改[代码]color[代码]的值,就会触发set方法,执行dep.notify(4-1)。 会遍历依赖数组,从而去触发Watcher的cb方法。 cb就是上面伪代码[代码]new Watcher[代码]的那个回调函数。 只要回调函数里面运行了操作dom方法,或者触发了diff算法更新dom,都可以把视图进行更新。 响应式简易流程大概就是这样了… 侦测数据变化的类型 其实数据监听变化有两种类型,一种是“推”(push),另一种是“拉”(pull)。 React和Angular中的变化侦测都属于“拉”,就是说在数据发生变化的时候,它不知道哪个数据变了,然后会发送一个信号给框架,框架收到信号后,会进行一个暴力比对来找出那些dom节点需要重新渲染。Angular中使用的是脏检查,在React使用虚拟dom的diff。 vue的数据监听属于“推”。当数据发生变化时,就知道哪个数据发生变化了,从而去更新有此数据依赖的视图。 因此,框架知道的数据更新信息越多,也就可以进行更细粒度的更新。比如,直接通过dom api操作dom。 相对“拉”的力度是最粗的。看到这里,是不是觉得vue的更新效率最快。 我们看看下面的例子 [代码]<template> <div>{{a}}</div> <div>{{b}}</div> </template> [代码] 这里我们看出模板只有a、b两个数据依赖,也就是说我们要创建两个闭包dep去存放两个watcher依赖,我们知道闭包的缺点就是内存泄露。如果有1000个数据依赖在模板上,每个数据所绑定的依赖就越多,依赖追踪在内存上的开销就会越大。 所以,从Vue.js2.0开始,它引入了虚拟dom,一个状态所绑定的依赖不再是具体的dom节点,而是一个组件,即一个组件一个Watcher。 这样状态变化后,会通知到组件,组件内部再使用虚拟 dom进行比对。这可以大大降低依赖数量,从而降低依赖追踪所消耗的内存。但并不是引入虚拟dom后,渲染速度变快了。准确的来说,应该是80%的场景下变得更快了,而剩下的20%反而变慢了。 个人觉得,鱼和熊掌不可兼得。 “推”是牺牲内存来换更新速度。 “拉”则是牺牲更新速度来获取内存。 总结 Vue响应式的灵魂:在getter中收集依赖,在setter中触发依赖。 [图片] 我们再看看图,回顾一下整个流程。 通过[代码]defineReactive[代码],遍历data里面的属性,把数据的getter/setter劫持,用来收集和触发依赖。 当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到Dep依赖中。 当数据发生变化时,会触发setter,从而Dep会向依赖(Wachter)发送通知。 Watcher收到通知后,会向外界发送通知,变化通知到外界后可能接触视图更新,也有可能触发用户的某个回调函数等等。 参考文献:《深入浅出Vue.js》
2019-12-31 - 小程序云开发 如何调用第三方接口
小程序云开发 如何调用第三方接口
2018-12-24 - 微信小程序开发UI组件样式库推荐
做为微信开发的程序员来说,写一些WXSS页面样式最头疼了。往往做出来的界面虽然功能一个不少,但显示的效果简陋而达不到用户满意。 我们推荐了以下几款微信小程序的组件库,可以让你不用懂WXSS也不用设计感,照样能做出很漂亮的小程序。 一、WeUIWEUI是一套基于样式库weui-wxss开发的小程序扩展组件库,同微信原生视觉体验一致的UI组件库,由微信官方设计团队和小程序团队为微信小程序量身设计,令用户的使用感知更加统一。 [图片] [图片] 官方组件库能够满足基础的界面需求,但是,如果你想要更加饱满的视觉,更加活泼的动效,恐怕 WeUI 就满足不了你的需要了。 GitHub 地址:https://github.com/Tencent/weui 二、ColorUI 组件库ColorUI 是一款高颜值组件库,侧重于视觉交互。比起 WeUI 的低调克制,ColorUI 色彩鲜亮,样式繁多。除了拥有非常丰富的原生组件的自定义样式,它还提供一些常见的页面元素,比如时间轴、步骤条、聊天页、模态窗口等等。 [图片] 这些页面元素通常应用在哪些场景下呢? 如果你想做一款诸如日记类、记账类、博客类、Vlog 类的小程序,这时就需要用到「时间轴」。 如果你想做一款涉及流程的小程序,比如物流跟踪,工作审批等,「步骤条」就可以派上用场了。 如果你想做一款社交类小程序,那么,当然少不得要用到「聊天」的界面。 而「模态窗口」则可以应用于各类小程序中出现弹框、侧边栏的地方。 [图片] 此外,ColorUI 还引入了插件扩展,也就是更为复杂的组件。目前已有的扩展包括索引列表、微动画、全屏抽屉以及垂直导航。引用这几项扩展,只需编写少量代码,就能实现较炫的视觉交互,进一步简化了开发工作。 [图片] 前面我们已经提到,ColorUI 是侧重于视觉交互的组件库,这方面的表现,还在于它为用户提供了色彩的搭配方案。打开「背景」,可以看到深色、淡色、渐变等多种配色。 [图片] ColorUI 还有许多值得推荐的地方。多样化的示例就是其中之一,它详尽地向用户展示了各种情况下,开发者可能需要编写的样式。 比如,打开「头像」,就会看到被一一列举的圆形头像、圆角矩形头像、各种尺寸头像、默认头像、文字头像、彩色头像、头像组、贴标签头像等等。一个这么简单的组件,也可以有许多种不同的呈现方式。 [图片] 又比如,打开「列表」,不仅可以看到宫格列表、菜单列表、消息列表、左滑列表等基本的样式,还可以设置一些可选项,像边框、箭头等,在细节处也有多种可选样式。 [图片] ColorUI 给大家提供了高度自定义的组件,一些比较麻烦的样式,开发者只需调用其组件就能得以实现。不过,ColorUI 也不是万能的,比如,它尚未涉及购物类小程序所需的组件。 GitHub 地址:https://github.com/weilanwl/ColorUI [图片] 三、Vant 组件库演示Vant 是由有赞发布的,轻量的小程序 UI 组件库。如果你想制作一款电商、餐饮、外卖平台、票务预订等购物类小程序,选用 Vant 是较为合适的。为什么这么说呢? [图片] 首先,我们来看「业务组件」这一块。可以看到,「商品卡片」与「提交订单栏」两个组件可以构成一个基本的「购物车」页面;而「商品卡片」与「商品导航」二者又可以组成一个简单的商店页面。 [图片] 我们再看看其他琐碎的组件,比如「表单组件」中的「评分」、「搜索」、「步进器」,都属于购物类小程序需要用到的组件。 [图片] 「导航组件」中的「徽章」与「展示组件」中的「分类选择」,都可以用于商品品类的选择切换。 [图片] 「展示组件」中的「折叠面板」与「面板」可以用作详细介绍商品的组件,「步骤条」则可以用于显示物流跟踪信息。 [图片] 使用 Vant 组件库,除了可以用常用的 Toast 方法,向用户弹出提醒消息,还可以引用「反馈组件」中的「消息通知」以及「展示组件」中的「通告栏」,向用户输出通知信息。 [图片] 除了以上可用于购物类小程序的组件,Vant 组件库当然还有那些比较通用基本元素、弹出层、Transition 动画等。值得一提的是,Vant 还支持自定义 Actionsheet,在「反馈组件」的「上拉菜单」中,有三种不同的自定义 Actionsheet。 [图片] Vant 对开发者非常友好,文档可以说是事无巨细了,而且在文档右侧,还可以预览样式哦。 开发文档:https://youzan.github.io/vant-weapp/#/intro GitHub 地址:https://github.com/youzan/vant-weapp [图片] 四、iViewUIiViewUI 是由 TalkingData 发布的组件库。作为一款好用的组件库,布局、面板、列表、表单、顶部导航栏、底部导航栏等组件当然必不可少,那么 iViewUI 除了具备这些标配的组件,还有哪些亮点呢? [图片] 在「导航」分类下,「分页」、「索引选择器」以及「吸顶容器」都是比较实用的组件。 其中,「索引选择器」与 ColorUI 中的「索引列表」是同类组件,不同的是,ColorUI 的「索引列表」中每一项可以包含图片、名字与描述,且支持搜索,而 iViewUI 的「索引选择器」中每一项只包含名字,且不支持搜索。 而「吸顶容器」在上文中尚未提及,这一组件适合用于分级长列表的显示。 [图片] 在「视图」分类下的「倒计时」一项中,提供了多种倒计时的显示格式。 [图片] iViewUI 同样有详细的文档,但是不支持网页预览,只能打开小程序预览。 开发文档:https://weapp.iviewui.com/docs/guide/start GitHub 地址:https://github.com/TalkingData/iview-weapp [图片] 五、MinUI 组件库MinUI 是由蘑菇街发布的组件库。与其他组件库不同的是,MinUI 更注重一些细节的处理。 [图片] 调用「基础元件」中的「文本截断」,可以控制长文本的显示行数,文本超长的用省略号结尾。「页底提示」可以用在上拉加载中的过程中。而「价格」则提供了各种样式的价格及货币符号。 [图片] 「功能组件」的「异常流展示」为开发者提供了各种异常状态下,向用户展示的界面。「遮罩层」则提供了各种效果的遮罩层,及其显示、隐藏方式。 [图片] 相比其他组件库,MinUI 将各种组件拆分得更细,真正使用时,需要开发者更多的对各个组件进行再次结合,但也因此 MinUI 显得更加通用。 开发文档:https://meili.github.io/min/docs/minui/index.html#README GitHub 地址:https://github.com/meili/min-cli [图片] 六、TaroUITaroUI 是由京东·凹凸实验室发布的多端 UI 组件库。这套组件库,可以在 H5、微信小程序、支付宝小程序、百度小程序多端适配运行。TaroUI 的整体风格简约、清新、统一,适合工具、读书、资讯、教育、商务等类型的小程序。 [图片] 除了拥有上文所提及的组件之外,TaroUI 还有几个特别的组件。在「表单」中有一项「范围选择器」,可以通过滑动条指定数值范围。在「高阶组件」中,可以显示「日历」,并且支持多种日期选择样式。 [图片] TaroUI 同样拥有健全的开发文档,也支持在网页中预览手机效果。 开发文档:https://taro-ui.aotu.io/#/docs/introduction GitHub 地址:https://github.com/NervJS/taro-ui [图片] 七、WuxUI这套组件库所包含的组件最为丰富。不仅我们前文提到的各类组件都可以在 Wux 中找到,而且还有进度环、骨架屏、筛选栏、数字键盘、结果页等实用工具类组件。如果你想开发一款工具类小程序,Wux 是个不错的选择。 [图片] 开发文档:https://wux-weapp.github.io/wux-weapp-docs/#/introduce GitHub 地址:https://github.com/wux-weapp/wux-weapp/ [图片] 这 7 款 UI 组件库各有所长,适合不同的小程序类型,Vant 适合电商类的,TaroUI 与 Wux 适合工具类的,而蘑菇街的 MinUI 当然更适合社区类的了。 大家可以根据自己的需求来选择相应的UI组件库来创建制作微信小程序。有微信小程序开发需求也可以联系云梁网络(https://www.yunliangwang.com)
2019-10-29 - 浅谈小程序运行机制
摘要: 理解小程序原理… 原文:浅谈小程序运行机制 作者:小白 Fundebug经授权转载,版权归原作者所有。 写作背景 接触小程序有一段时间了,总得来说小程序开发门槛比较低,但其中基本的运行机制和原理还是要懂的。“比如我在面试的时候问到一个关于小程序的问题,问小程序有window对象吗?他说有吧”,但其实是没有的。感觉他并没有了解小程序底层的一些东西,归根结底来说应该只能算会使用这个工具,但并不明白其中的道理。 小程序与普通网页开发是有很大差别的,这就要从它的技术架构底层去剖析了。还有比如习惯Vue,react开发的开发者会吐槽小程序新建页面的繁琐,page必须由多个文件组成、组件化支持不完善、每次更改 data 里的数据都得setData、没有像Vue方便的watch监听、不能操作Dom,对于复杂性场景不太好,之前不支持npm,不支持sass,less预编译处理语言。 “有的人说小程序就像被阉割的Vue”,哈哈当然了,他们从设计的出发点就不同,咱也得理解小程序设计的初衷,通过它的使用场景,它为什么采用这种技术架构,这种技术架构有什么好处,相信在你了解完这些之后,就会理解了。下面我会从以下几个角度去分析小程序的运行机制和它的整体技术架构。 了解小程序的由来 在小程序没有出来之前,最初微信WebView逐渐成为移动web重要入口,微信发布了一整套网页开发工具包,称之为 JS-SDK,给所有的 Web 开发者打开了一扇全新的窗户,让所有开发者都可以使用到微信的原生能力,去完成一些之前做不到或者难以做到的事情。 但JS-SDK 的模式并没有解决使用移动网页遇到的体验不良的问题,比如受限于设备性能和网络速度,会出现白屏的可能。因此又设计了一个增强版JS-SDK,也就是“微信 Web 资源离线存储”,但在复杂的页面上依然会出现白屏的问题,原因表现在页面切换的生硬和点击的迟滞感。这个时候需要一个 JS-SDK 所处理不了的,使用户体验更好的一个系统,小程序应运而生。 快速的加载 更强大的能力 原生的体验 易用且安全的微信数据开放 高效和简单的开发 小程序与普通网页开发的区别 小程序的开发同普通的网页开发相比有很大的相似性,小程序的主要开发语言也是 JavaScript,但是二者还是有些差别的。 普通网页开发可以使用各种浏览器提供的 DOM API,进行 DOM 操作,小程序的逻辑层和渲染层是分开的,逻辑层运行在 JSCore 中,并没有一个完整浏览器对象,因而缺少相关的DOM API和BOM API。 普通网页开发渲染线程和脚本线程是互斥的,这也是为什么长时间的脚本运行可能会导致页面失去响应,而在小程序中,二者是分开的,分别运行在不同的线程中。 网页开发者在开发网页的时候,只需要使用到浏览器,并且搭配上一些辅助工具或者编辑器即可。小程序的开发则有所不同,需要经过申请小程序帐号、安装小程序开发者工具、配置项目等等过程方可完成。 小程序的执行环境 [图片] 小程序架构 一、技术选型 一般来说,渲染界面的技术有三种: 用纯客户端原生技术来渲染 用纯 Web 技术来渲染 用客户端原生技术与 Web 技术结合的混合技术(简称 Hybrid 技术)来渲染 通过以下几个方面分析,小程序采用哪种技术方案 开发门槛:Web 门槛低,Native 也有像 RN 这样的框架支持 体验:Native 体验比 Web 要好太多,Hybrid 在一定程度上比 Web 接近原生体验 版本更新:Web 支持在线更新,Native 则需要打包到微信一起审核发布 管控和安全:Web 可跳转或是改变页面内容,存在一些不可控因素和安全风险 由于小程序的宿主环境是微信,如果用纯客户端原生技术来编写小程序,那么小程序代码每次都需要与微信代码一起发版,这种方式肯定是不行的。 所以需要像web技术那样,有一份随时可更新的资源包放在云端,通过下载到本地,动态执行后即可渲染出界面。如果用纯web技术来渲染小程序,在一些复杂的交互上可能会面临一些性能问题,这是因为在web技术中,UI渲染跟JavaScript的脚本执行都在一个单线程中执行,这就容易导致一些逻辑任务抢占UI渲染的资源。 所以最终采用了两者结合起来的Hybrid 技术来渲染小程序,可以用一种近似web的方式来开发,并且可以实现在线更新代码,同时引入组件也有以下好处: 扩展 Web 的能力。比如像输入框组件(input, textarea)有更好地控制键盘的能力 体验更好,同时也减轻 WebView 的渲染工作 绕过 setData、数据通信和重渲染流程,使渲染性能更好 用客户端原生渲染内置一些复杂组件,可以提供更好的性能 二、双线程模型 小程序的渲染层和逻辑层分别由 2 个线程管理:视图层的界面使用了 WebView 进行渲染,逻辑层采用 JsCore 线程运行 JS脚本。 [图片] [图片] 那么为什么要这样设计呢,前面也提到了管控和安全,为了解决这些问题,我们需要阻止开发者使用一些,例如浏览器的window对象,跳转页面、操作DOM、动态执行脚本的开放性接口。 我们可以使用客户端系统的 JavaScript 引擎,iOS 下的 JavaScriptCore 框架,安卓下腾讯 x5 内核提供的 JsCore 环境。 这个沙箱环境只提供纯 JavaScript 的解释执行环境,没有任何浏览器相关接口。 这就是小程序双线程模型的由来: 逻辑层:创建一个单独的线程去执行 JavaScript,在这里执行的都是有关小程序业务逻辑的代码,负责逻辑处理、数据请求、接口调用等 视图层:界面渲染相关的任务全都在 WebView 线程里执行,通过逻辑层代码去控制渲染哪些界面。一个小程序存在多个界面,所以视图层存在多个 WebView 线程 JSBridge 起到架起上层开发与Native(系统层)的桥梁,使得小程序可通过API使用原生的功能,且部分组件为原生组件实现,从而有良好体验 三、双线程通信 把开发者的 JS 逻辑代码放到单独的线程去运行,但在 Webview 线程里,开发者就没法直接操作 DOM。 那要怎么去实现动态更改界面呢? 如上图所示,逻辑层和试图层的通信会由 Native (微信客户端)做中转,逻辑层发送网络请求也经由 Native 转发。 这也就是说,我们可以把 DOM 的更新通过简单的数据通信来实现。 Virtual DOM 相信大家都已有了解,大概是这么个过程:用 JS 对象模拟 DOM 树 -> 比较两棵虚拟 DOM 树的差异 -> 把差异应用到真正的 DOM 树上。 如图所示: [图片] 1. 在渲染层把 WXML 转化成对应的 JS 对象。 2. 在逻辑层发生数据变更的时候,通过宿主环境提供的 setData 方法把数据从逻辑层传递到 Native,再转发到渲染层。 3. 经过对比前后差异,把差异应用在原来的 DOM 树上,更新界面。 我们通过把 WXML 转化为数据,通过 Native 进行转发,来实现逻辑层和渲染层的交互和通信。 而这样一个完整的框架,离不开小程序的基础库。 四、小程序的基础库 小程序的基础库可以被注入到视图层和逻辑层运行,主要用于以下几个方面: 在视图层,提供各类组件来组建界面的元素 在逻辑层,提供各类 API 来处理各种逻辑 处理数据绑定、组件系统、事件系统、通信系统等一系列框架逻辑 由于小程序的渲染层和逻辑层是两个线程管理,两个线程各自注入了基础库。 小程序的基础库不会被打包在某个小程序的代码包里边,它会被提前内置在微信客户端。 这样可以: 降低业务小程序的代码包大小 可以单独修复基础库中的 Bug,无需修改到业务小程序的代码包 五、Exparser 框架 Exparser是微信小程序的组件组织框架,内置在小程序基础库中,为小程序的各种组件提供基础的支持。小程序内的所有组件,包括内置组件和自定义组件,都由Exparser组织管理。 Exparser的主要特点包括以下几点: 基于Shadow DOM模型:模型上与WebComponents的ShadowDOM高度相似,但不依赖浏览器的原生支持,也没有其他依赖库;实现时,还针对性地增加了其他API以支持小程序组件编程。 可在纯JS环境中运行:这意味着逻辑层也具有一定的组件树组织能力。 高效轻量:性能表现好,在组件实例极多的环境下表现尤其优异,同时代码尺寸也较小。 小程序中,所有节点树相关的操作都依赖于Exparser,包括WXML到页面最终节点树的构建、createSelectorQuery调用和自定义组件特性等。 内置组件 基于Exparser框架,小程序内置了一套组件,提供了视图容器类、表单类、导航类、媒体类、开放类等几十种组件。有了这么丰富的组件,再配合WXSS,可以搭建出任何效果的界面。在功能层面上,也满足绝大部分需求。 六、运行机制 小程序启动会有两种情况,一种是「冷启动」,一种是「热启动」。假如用户已经打开过某小程序,然后在一定时间内再次打开该小程序,此时无需重新启动,只需将后台状态的小程序切换到前台,这个过程就是热启动;冷启动指的是用户首次打开或小程序被微信主动销毁后再次打开的情况,此时小程序需要重新加载启动。 小程序没有重启的概念 当小程序进入后台,客户端会维持一段时间的运行状态,超过一定时间后(目前是5分钟)会被微信主动销毁 当短时间内(5s)连续收到两次以上收到系统内存告警,会进行小程序的销毁 [图片] 七、更新机制 小程序冷启动时如果发现有新版本,将会异步下载新版本的代码包,并同时用客户端本地的包进行启动,即新版本的小程序需要等下一次冷启动才会应用上。 如果需要马上应用最新版本,可以使用 wx.getUpdateManager API 进行处理。 八、性能优化 主要的优化策略可以归纳为三点: 精简代码,降低WXML结构和JS代码的复杂性; 合理使用setData调用,减少setData次数和数据量; 必要时使用分包优化。 1、setData 工作原理 小程序的视图层目前使用 WebView 作为渲染载体,而逻辑层是由独立的 JavascriptCore 作为运行环境。在架构上,WebView 和 JavascriptCore 都是独立的模块,并不具备数据直接共享的通道。当前,视图层和逻辑层的数据传输,实际上通过两边提供的 evaluateJavascript 所实现。即用户传输的数据,需要将其转换为字符串形式传递,同时把转换后的数据内容拼接成一份 JS 脚本,再通过执行 JS 脚本的形式传递到两边独立环境。 而 evaluateJavascript 的执行会受很多方面的影响,数据到达视图层并不是实时的。 2、常见的 setData 操作错误 频繁的去 setData在我们分析过的一些案例里,部分小程序会非常频繁(毫秒级)的去setData,其导致了两个后果:Android下用户在滑动时会感觉到卡顿,操作反馈延迟严重,因为 JS 线程一直在编译执行渲染,未能及时将用户操作事件传递到逻辑层,逻辑层亦无法及时将操作处理结果及时传递到视图层;渲染有出现延时,由于 WebView 的 JS 线程一直处于忙碌状态,逻辑层到页面层的通信耗时上升,视图层收到的数据消息时距离发出时间已经过去了几百毫秒,渲染的结果并不实时; 每次 setData 都传递大量新数据由setData的底层实现可知,我们的数据传输实际是一次 evaluateJavascript 脚本过程,当数据量过大时会增加脚本的编译执行时间,占用 WebView JS 线程, 后台态页面进行 setData当页面进入后台态(用户不可见),不应该继续去进行setData,后台态页面的渲染用户是无法感受的,另外后台态页面去setData也会抢占前台页面的执行。 总结 大致从以上几个角度分析了小程序的底层架构,从小程序的由来、到双线程的出现、设计、通信、到基础库、Exparser 框架、再到运行机制、性能优化等等,都是一个个相关而又相互影响的选择。关于小程序的底层框架设计,其实涉及到的还有很多,比如自定义组件,原生组件、性能优化等方面,都不是一点能讲完的,还要多看源码,多思考。每一个框架的诞生都有其意义,我们作为开发者能做的不只是会使用这个工具,还应理解它的设计模式。只有这样才不会被工具左右,才能走的更远!
2019-06-14 - 小程序开发另类小技巧 --用户授权篇
小程序开发另类小技巧 --用户授权篇 getUserInfo较为特殊,不包含在本文范围内,主要针对需要授权的功能性api,例如:wx.startRecord,wx.saveImageToPhotosAlbum, wx.getLocation 原文地址:https://www.yuque.com/jinxuanzheng/gvhmm5/arexcn 仓库地址:https://github.com/jinxuanzheng01/weapp-auth-demo 背景 小程序内如果要调用部分接口需要用户进行授权,例如获取地理位置信息,收获地址,录音等等,但是小程序对于这些需要授权的接口并不是特别友好,最明显的有两点: 如果用户已拒绝授权,则不会出现弹窗,而是直接进入接口 fail 回调, 没有统一的错误信息提示,例如错误码 一般情况而言,每次授权时都应该激活弹窗进行提示,是否进行授权,例如: [图片] 而小程序内只有第一次进行授权时才会主动激活弹窗(微信提供的),其他情况下都会直接走fail回调,微信文档也在句末添加了一句请开发者兼容用户拒绝授权的场景, 这种未做兼容的情况下如果用户想要使用录音功能,第一次点击拒绝授权,那么之后无论如何也无法再次开启录音权限**,很明显不符合我们的预期。 所以我们需要一个可以进行二次授权的解决方案 常见处理方法 官方demo 下面这段代码是微信官方提供的授权代码, 可以看到也并没有兼容拒绝过授权的场景查询是否授权(即无法再次调起授权) [代码]// 可以通过 wx.getSetting 先查询一下用户是否授权了 "scope.record" 这个 scope wx.getSetting({ success(res) { if (!res.authSetting['scope.record']) { wx.authorize({ scope: 'scope.record', success () { // 用户已经同意小程序使用录音功能,后续调用 wx.startRecord 接口不会弹窗询问 wx.startRecord() } }) } } }) [代码] 一般处理方式 那么正常情况下我们该怎么做呢?以地理位置信息授权为例: [代码]wx.getLocation({ success(res) { console.log('success', res); }, fail(err) { // 检查是否是因为未授权引起的错误 wx.getSetting({ success (res) { // 当未授权时直接调用modal窗进行提示 !res.authSetting['scope.userLocation'] && wx.showModal({ content: '您暂未开启权限,是否开启', confirmColor: '#72bd4a', success: res => { // 用户确认授权后,进入设置列表 if (res.confirm) { wx.openSetting({ success(res){ // 查看设置结果 console.log(!!res.authSetting['scope.userLocation'] ? '设置成功' : '设置失败'); }, }); } } }); } }); } }); [代码] 上面代码,有些同学可能会对在fail回调里直接使用wx.getSetting有些疑问,这里主要是因为 微信返回的错误信息没有一个统一code errMsg又在不同平台有不同的表现 从埋点数据得出结论,调用这些api接口出错率基本集中在未授权的状态下 这里为了方便就直接调用权限检查了 ,也可以稍微封装一下,方便扩展和复用,变成: [代码] bindGetLocation(e) { let that = this; wx.getLocation({ success(res) { console.log('success', res); }, fail(err) { that.__authorization('scope.userLocation'); } }); }, bindGetAddress(e) { let that = this; wx.chooseAddress({ success(res) { console.log('success', res); }, fail(err) { that.__authorization('scope.address'); } }); }, __authorization(scope) { /** 为了节省行数,不细写了,可以参考上面的fail回调,大致替换了下变量res.authSetting[scope] **/ } [代码] 看上去好像没有什么问题,fail里只引入了一行代码, 这里如果只针对较少页面的话我认为已经够用了,毕竟**‘如非必要,勿增实体’,但是对于小打卡这个小程序来说可能涉及到的页面,需要调用的场景偏多**,我并不希望每次都人工去调用这些方法,毕竟人总会犯错 梳理目标 上文已经提到了背景和常见的处理方法,那么梳理一下我们的目标,我们到底是为了解决什么问题?列了下大致为下面三点: 兼容用户拒绝授权的场景,即提供二次授权 解决多场景,多页面调用没有统一规范的问题 在底层解决,业务层不需要关心二次授权的问题 扩展wx[funcName]方法 为了节省认知成本和减少出错概率,我希望他是这个api默认携带的功能,也就是说因未授权出现错误时自动调起是否开启授权的弹窗 为了实现这个功能,我们可能需要对wx的原生api进行一层包装了(关于页面的包装可以看:如何基于微信原生构建应用级小程序底层架构) 为wx.getLocation添加自己的方法 这里需要注意的一点是直接使用常见的装饰模式是会出现报错,因为wx这个对象在设置属性时没有设置set方法,这里需要单独处理一下 [代码]// 直接装饰,会报错 Cannot set property getLocation of #<Object> which has only a getter let $getLocation = wx.getLocation; wx.getLocation = function (obj) { $getLocation(obj); }; // 需要做一些小处理 wx = {...wx}; // 对wx对象重新赋值 let $getLocation = wx.getLocation; wx.getLocation = function (obj) { console.log('调用了wx.getLocation'); $getLocation(obj); }; // 再次调用时会在控制台打印出 '调用了wx.getLocation' 字样 wx.getLocation() [代码] 劫持fail方法 第一步我们已经控制了wx.getLocation这个api,接下来就是对于fail方法的劫持,因为我们需要在fail里加入我们自己的授权逻辑 [代码]// 方法劫持 wx.getLocation = function (obj) { let originFail = obj.fail; obj.fail = async function (errMsg) { // 0 => 已授权 1 => 拒绝授权 2 => 授权成功 let authState = await authorization('scope.userLocation'); // 已授权报错说明并不是权限问题引起,所以继续抛出错误 // 拒绝授权,走已有逻辑,继续排除错误 authState !== 2 && originFail(errMsg); }; $getLocation(obj); }; // 定义检查授权方法 function authorization(scope) { return new Promise((resolve, reject) => { wx.getSetting({ success (res) { !res.authSetting[scope] ? wx.showModal({ content: '您暂未开启权限,是否开启', confirmColor: '#72bd4a', success: res => { if (res.confirm) { wx.openSetting({ success(res){ !!res.authSetting[scope] ? resolve(2) : resolve(1) }, }); }else { resolve(1); } } }) : resolve(0); } }) }); } // 业务代码中的调用 bindGetLocation(e) { let that = this; wx.getLocation({ type: 'wgs84', success(res) { console.log('success', res); }, fail(err) { console.warn('fail', err); } }); } [代码] 可以看到现在已实现的功能已经达到了我们最开始的预期,即因授权报错作为了wx.getLocation默认携带的功能,我们在业务代码里再也不需要处理任何再次授权的逻辑 也意味着wx.getLocation这个api不论在任何页面,组件,出现频次如何,**我们都不需要关心它的授权逻辑(**效果本来想贴gif图的,后面发现有图点大,具体效果去git仓库跑一下demo吧) 让我们再优化一波 上面所述大致是整个原理的一个思路,但是应用到实际项目中还需要考虑到整体的扩展性和维护成本,那么就让我们再来优化一波 代码包结构: 本质上只要在app.js这个启动文件内,引用./x-wxx/index文件对原有的wx对象进行覆盖即可 [图片] **简单的代码逻辑: ** [代码]// 大致流程: //app.js wx = require('./x-wxx/index'); // 入口处引入文件 // x-wxx/index const apiExtend = require('./lib/api-extend'); module.exports = (function (wxx) { // 对原有方法进行扩展 wxx = {...wxx}; for (let key in wxx) { !!apiExtend[key] && (()=> { // 缓存原有函数 let originFunc = wxx[key]; // 装饰扩展的函数 wxx[key] = (...args) => apiExtend[key](...args, originFunc); })(); } return wxx; })(wx); // lib/api-extend const Func = require('./Func'); (function (exports) { // 需要扩展的api(类似于config) // 获取权限 exports.authorize = function (opts, done) { // 当调用为"确认授权方法时"直接执行,避免死循环 if (opts.$callee === 'isCheckAuthApiSetting') { console.log('optsopts', opts); done(opts); return; } Func.isCheckAuthApiSetting(opts.scope, () => done(opts)); }; // 选择地址 exports.chooseAddress = function (opts, done) { Func.isCheckAuthApiSetting('scope.address', () => done(opts)); }; // 获取位置信息 exports.getLocation = function (opts, done) { Func.isCheckAuthApiSetting('scope.userLocation', () => done(opts)); }; // 保存到相册 exports.saveImageToPhotosAlbum = function (opts, done) { Func.isCheckAuthApiSetting('scope.writePhotosAlbum', () => done(opts)); } // ...more })(module.exports); [代码] 更多的玩法 可以看到我们无论后续扩展任何的微信api,都只需要在lib/api-extend.js 配置即可,这里不仅仅局限于授权,也可以做一些日志,传参的调整,例如: [代码] // 读取本地缓存(同步) exports.getStorageSync = (key, done) => { let storage = null; try { storage = done(key); } catch (e) { wx.$logger.error('getStorageSync', {msg: e.type}); } return storage; }; [代码] 这样是不是很方便呢,至于Func.isCheckAuthApiSetting这个方法具体实现,为了节省文章行数请自行去git仓库里查看吧 关于音频授权 录音授权略为特殊,以wx.getRecorderManager为例,它并不能直接调起录音授权,所以并不能直接用上述的这种方法,不过我们可以曲线救国,达到类似的效果,还记得我们对于wx.authorize的包装么,本质上我们是可以直接使用它来进行授权的,比如将它用在我们已经封装好的录音管理器的start方法进行校验 [代码]wx.authorize({ scope: 'scope.record' }); [代码] 实际上,为方便统一管理,Func.isCheckAuthApiSetting方法其实都是使用wx.authorize来实现授权的 [代码]exports.isCheckAuthApiSetting = async function(type, cb) { // 简单的类型校验 if(!type && typeof type !== 'string') return; // 声明 let err, result; // 获取本地配置项 [err, result] = await to(getSetting()); // 这里可以做一层缓存,检查缓存的状态,如果已授权可以不必再次走下面的流程,直接return出去即可 if (err) { return cb('fail'); } // 当授权成功时,直接执行 if (result.authSetting[type]) { return cb('success'); } // 调用获取权限 [err, result] = await to(authorize({scope: type, $callee: 'isCheckAuthApiSetting'})); if (!err) { return cb('success'); } } [代码] 关于用户授权 用户授权极为特殊,因为微信将wx.getUserInfo升级了一版,没有办法直接唤起了,详见《公告》,所以需要单独处理,关于这里会拆出单独的一篇文章来写一些有趣的玩法 总结 最后稍微总结下,通过上述的方案,我们解决了最开始目标的同时,也为wx这个对象上的方法提供了统一的装饰接口(lib/api-extend文件),便于后续其他行为的操作比如埋点,日志,参数校验 还是那么一句话吧,小程序不管和web开发有多少不同,本质上都是在js环境上进行开发的,希望小程序的社区环境更加活跃,带来更多有趣的东西
2019-06-14 - 小打卡|如何组件化拆分一个200+页面的小程序
大家好,我是小打卡的前端唐驰。刚才金轩正同学分享了基于原生小程序底层架构,在此基础上我为大家分享下如何拆分一个200+页面的小程序,主要通过以下几点来聊一聊小打卡在组件化路上的一些实践 1.背景 2.组件与方案 3.组件间通讯 4.基于组件我们做了哪些事 [图片] [图片] 1. 其实一开始小打卡是没有引入组件化的,因为微信最开始是不支持组件化的。当时js代码已经4k+行了,各种功能代码,有用的没有用的,不知道干什么的代码就躺在那里,一动不动。举个例子,一个头像点击跳转的逻辑搜索了下,遍布在各个页面。修改起来可想而知的胆战心惊。另一个原因就是当时由于业务功能直线上升,很快我们就遇到了代码包超包了。在微信还没有实现分包之前,我们就只能一个一个页面的去review剔除代码,效率极低。这也是促成我们决定寻求出路的原因之一。可是删代码删功能是不能解决问题,期间我们也考虑过h5的方式,跑了demo之后却发现h5方式的多次渲染, 与加载首页白屏,尽管有各种服务端渲染方案,但是我们一致觉得为了用户体验,放弃了。 [图片] 2. 对于小打卡来说,我们不能再任由项目裸奔了,需要一种开发方式来进行约束,主要是有几个诉求: 在之前的项目上,为了方便。功能与功能之间的耦合程度极其的高,各种为了使用方便而随意修改某一个方法。 1.降低页面上各个功能点的耦合程度 我们不希望同一个功能点同样的代码在页面肆意copy,这样带来了极高的维护成本。以至后面无法维护。并且功能的复用不希望是copy,前端与后端不同的是不仅是单单的逻辑复用、更有布局、样式等。 2.提供代码的可复用性、可维护性 对于一个程序员来说,如果你打开一个代码文件。映入眼帘的是密密麻麻的代码,行数达到好几千K行,我相信大家的第一反应是抗拒的,更别说去修改代码,天知道会改出什么问题。 3.降低单一文件的复杂度 4.如果是公共功能的化我们还希望它能够有自己的作用域,保持自己的独立性。 [图片] 3. 根据以上几点,我们用一个页面举例,如何去拆分一个页面,首先我们需要有以下几点认识: 决定一个页面如何组件化的前提是该页面的功能是否是有全局都需要的功能模块 功能模块是否需要与页面其他模块强耦合 单个功能模块逻辑是否过于复杂(占用代码空间过大)——>单纯是为了页面代码的可读性。 不是全拆成组件就是最好的,不能为了组件而组件化 [图片] 4. 说了这么多,其实我们应该首先应该了解下,组件的特性? 专一性(一个组件只干一件事情,或者某一类事情。)功能的高度内聚 比如说右侧的feed集上的头像、它是一个组件、就负责显示头像跟跳转,其他的事它都不参与 可配置(能够适应通过设置属性值的方式来输出不同的东西)输入影响部分输出 然后我们同时可以设置头像组件上的size属性来设置头像在不同页面下的大小样式 生命周期(组件可以在自身或者说所在页面的生命周期内可以做不同的事情)比如可以在组件生成的时候进行数的初始化、属性值的类型校验、组件销毁时并同时销毁定时器等其他任务 事件传递 (既然要让组件与页面保持独立性,那么组件与页面的通讯交互就得需要一个标准) 右侧的feed组件其实是一个组件集合、我们通过组合不同的组件然后就形成了feed组件。就跟搭积木一样、只需要引入组件就行了。特别方便。 [图片] 5. 说到组件,那么小程序早期的不支持自定义组件开发这就很让人头疼、同样的feed组件我们经历了几乎三个版本的大改动、从最开始的直接写在页面里,后台使用template方式、再到后来的自定义组件方式。所以我们的演进步骤就成了page->template->component, 这儿列了一个表格对比了下几种组件化方式的对比。 可以看到,include的方式其实是最鸡肋的,include的方式其实实际意义上我理解成更多的是代码的切割,并且还不能将(template、wxs)分出去、所以这种方式我们直接pass掉了, 而template的方式其实是我们曾经主力使用的方式、到现在我们也还在使用、相对于include来说,template有了独立的作用域、虽然css、跟js还是与页面共享的。但是已经可以做一些比较简单的事情了。 对于component来说,完完全全的组件,满足了组件的所有要求。 [图片] 6. 先说说template的方式吧,举个列子,这个是我们的使用template构建的头像组件。跟写页面的方式很像、同样是js、wxss、wxml组成。用名称来命名。但是由于微信当时没有很方的方式去引用这些文件,或者说没有一种方法可以打包供我们很方便的使用。但是比起之前直接copy代码的方式、这样通过引用的方式使用其实感觉已经好了很多了。 [图片] [图片] 7. 具体的使用方式我画了张图,对应组件内文件与页面文件的对应方式、这里对于js的引用其实我们是做了一些小动作, 我们在调用Page方法前做了一次page方法与组件方法的check,因为在page代码里我们不能保证所有的方法名不会跟组件内的方法名不会冲突,所以我们做了这个一个检查、 然后mix函数还做了另一个事情就是将page方法与组件方法合并。然后对于mix函数其实我们还可以做很多事情、、比如规范生命周期回调函数放在一个对象内,然后我们自己定义的方法放在另一个对象里,就跟vue一样。 But,在经历了一段template组件化的时间后,我们又觉得这个方式还是有点烂,为什么呢?在使用时仍然不能避免引入众多的文件、虽然我们对js文件做了处理,但是wxss的样式仍然会被污染、js与page仍然共享作用域。并不能成为一个真正的标准组件。好在后来,微信上了自定义组件的功能,接下来聊聊这个标准的微信自定义组件吧。 8. 微信提供了自定义组件的功能后我们也第一时间跟进了,相对于template这种方式来说,现在是真正的独立于页面存在。使用也比之前更为方便与简洁,右图是我们对component的一个项目目录划分。我们将component划分为了公共组件与页面组件、为什么会有页面组件, 1.是为了降低页面代码的复杂度 2.为了好看。 公共组件就不说了,一定是最基础、最通用的组件。 [图片] 9. 转向component方式后有一个问题逐渐便凸显出来了,由于组件的独立作用域,组件间的通讯就成了一个问题,接下来聊一聊组件的事件传递。微信最开始的时候提供了一种triggerEvent的方式,可是这样的方式似乎并不能满足我们某些场景下的需求。后来又提供了page下selectComponent方法来直接操作组件内部的属性与方法。然后还有就是基于我们自己的事件广播机制。这几种方式构成了小打卡现目前最主要的组件与page、组件与组件间的数据交互方式 [图片] 10. 先来说说triggerEvent模式,微信在自定义组件上可以自定义监听函数。我们在组件内将需要向外抛出的事件统一通过this.triggerEvent(‘invoke’,{handler:’fun’,data:{}})这个方法来执行。其中invoke对应了我们绑定在组件标签上的监听函数。而将需要外部执行的方法与数据通过数据的方式传给监听函数。而在page上面我通过统一的监听回调函数去自动执行需要执行的方法、这里的trigger与event都不要我们去手写在组件与page创建的时候底层就已经帮我们预置了,我们只需要关注业务开发就行。这是对于一部分需要page与组件交互的模式。而对于我们想直接操作组件方法而不需要反馈的模式就得使用selectComponent的模式 [图片] 11. 一个简单的列子:全局的toast组件。在需要弹出toast的时候我们想直接调用就行、不用在通过传值给组件、然后由组件来执行显示或隐藏。这类组件我们在组件目录里新增了一个lib的文件。在page里只需要引入这个lib文件然后就可以直接调用toast组件。lib主要是对this.selectCompent与执行逻辑的一个封装。 [图片] 12. 事件发布订阅模式:基于底层的eventBus。简化后我们用在了组件与组件之间的通讯上、特点是简单。 [图片] 13. 解决了组件间的通讯问题,可是对于公共组件的引用仍然让我们觉得麻烦与不畅快、所以我们构建了全局通用模版、它是干什么的呢。它提供给了一些基础的全局组件、比如自定义导航头、toast、loading等等。小打卡所有的页面都通过slot的方式插入到这个模版组件x-page下面。这样就解决了我们需要在每个页面引入公共组件的问题。另一个问题使用自定义导航栏的时定位起点会有状态栏下移动到屏幕左上方。会造成布局的错误。通过x-page可以很好解决这个问题而不用重新布局。并且通信问题也不用担心,都是由x-page组件作为中台来对内对外进行分发与执行。 [图片] [图片] [图片] 14. 通过以上小打卡的开发模式就基本形成。要做的事情还有很多,更多组件的玩儿法,对于现在或者将来我们正在做的。 是构建小打卡的组件与基础sdk的仓库。 拆分组件开发与业务开发。 通过npm包管理的方式来应对越来越多的小程序平台的开发。 或者通过形成小程序插件的方式供其他小伙伴使用。 [图片] [图片] 以上就是我今天分享的内容。谢谢。
2019-04-26 - 小打卡 | 如何基于微信原生构建应用级小程序底层架构(上)
[图片] 大家好,我是小打卡的前端负责人金轩正,今天分享的主题是如何基于微信原生构建应用级小程序底层架构,这个命题看上去好像有些大,不过不要紧,这次分享我把它拆一下,大致从 小程序原生开发面临的问题 小打卡整体架构演进 开发中摸索与实践 这三个方面来看这个讲一下 [图片] 小程序原生开发面临的问题[图片] ok,首先第一个方面原生开发遇到的问题 小程序从17年诞生2年来一直处于互联网风口,不过对于开发者而言的整个开发体验不是特别友好,在17-18年之间我和很多开发小程序的小伙伴们聊过,大多数的反馈可能分为下面大致几类,当然还有更多: 没有父类,无法使用继承挂载全局方法,扩展生命周期没有父类,无法使用继承挂载全局方法,扩展生命周期 不支持跨页面/多页面通讯 setData的性能瓶颈 代码包大小限制 1/2/4/8 M,没有npm包 代码发布流程繁琐 其根本原因是将刚刚诞生的小程序与已经非常成熟的React,vue,angular作对比,而没有将小程序作为一个新的生态来看待,当然这个是一种看待事物的进步,并不是倒退,我在这里说这句话的意思是有更多的问题需要我们开发者主动去解决问题,推动整个生态的前进与发展 [图片] 其实这里可能有些朋友会问,已经有很多优秀的框架已经解决了这些问题,那么为什么还要使用原生开发? 确实在这段时间内出现了很多优秀的解决方案,我们不用并不是因为情怀哈(当然还是有那么一丢丢) 更多的是下面几点: 历史包袱,改造成本过高 小打卡在小程序刚出现的时候就进入开发了,当时框架还不成熟,而且对创业公司来说时间和迭代效率高于一切,在人手不足,业务模式尚未形成,还处于探索阶段的情况下花费大量时间去做对产品影响较小, 甚至delay迭代速度事情不是很赚 减少与第三方沟通成本 高速迭代的情况下,将时间尽可能的覆盖于业务上,避免在整个开发-上线闭环上增加节点 避免开发黑盒,控制风险 虽然整个社区是非常活跃的,fixed一个问题同样是需要花费一定时间,但是很多时候需求是不会等你bug fixed 如非必要,勿增实体 即“简单有效原理”,这句话还是我去年刚来公司的时候和阿赖聊他所说过的 放在项目开发上我的理解是在架构层面要做的尽可能的薄,避免过度设计 这样才有足够的扩展性,灵活性,容错性 这些框架虽好,但是对我们当前业务来说可能过于复杂,比如跨端在之前的阶段还没有这方面需求,而像组件化小程序已经支持,自动化构建我们自己也是可以搭建的并不复杂 相信微信小程序团队 是真正的想把这件事情做好,而且做的是一个生态,不论是小程序对于反馈响应速度,和迭代速度非常给力,还是对开发者社区运营,比如是社区活跃与审核速度挂钩,社区周刊,优质个人和优质企业 对齐web标准,并且更加开放 [图片] 小打卡整体架构演进其实小打卡整个架构并非一蹴而就的,就像前面所说的如非必要,勿增实体,而是大量的实际开发中遇到的共同问题解决方案的集合题 [图片] 常规架构这个是微信小程序给出的快速开发模版的一个开发模式: server模块提供数据,App作为全局对象直连所有的业务模块,工具函数提供api处理业务模块的需求 优点: 整个模型非常简单,上手快,学习成本 低结构清晰,在业务不复杂的情况下可以快速开发 不瞒大家其实小打卡在最初的半年内基本都是这套模式。 当然是在业务不复杂的情况下,复杂情况下会出现哪些问题呢? App作为全局对象在有大量业务模块连接的情况下,代码很容易膨胀,在多人开发的时候问题非常明显,无论是fixed bug还是正常的业务开发都会造成麻烦 页面之间独立,缺少公共模块,唯一的工具函数又要尽可能保持单一职责来提供服务(小打卡当时就是因为这个问题导致很多工具函数内部存储直接修改外部状态,导致大量强耦函数合无法拆分) 业务层直连server层,未拆分数据层的情况下,基本不存在复用性 上面所述的问题,从我接手这个项目到真正的调整持续了挺长一段时间,主要是缺乏一个契机来进行优化 优化的转折点 [图片] 然后突然有一天产品同学跑过来说: 我们要有自己的核心数据仓库,我们要看实时数据 ok,涉及到数据采集的问题了,我这边从浅到深大概列了几项: 最基础的多个页面pv,uv如何监控,不可能每个页面都要手动收集 为了统计页面和事件的分享和回流的数据,需要在分享事件携带大量的参数 微信的wx.previewImage, wx.chooseImage 等api对于用户session的收集造成很大麻烦 我们先解决第一个问题,如何收集页面pv,uv 容易陷入的误区 [图片] 在解决问题之前,我们先说一下开发小程序容易进入的误区 App 和 Page 等函数工厂是微信原生提供,不可修改 小程序项目结构是基于App, Page, 工具函数三个模块构建的 小程序的全局存储只有globalData和本地缓存 其实产生这些误区最根本的原因是小程序没有提供在复杂业务逻辑下的开发范式,比如vue,react有自己的通用开发模版 如果保持这些观念来进行开发的话,很容易将路子走窄,并且难以解决一些实际上的问题, 其实不论小程序和传统web有多少不同, 本质上还是在js环境下开发 小打卡架构图解 [图片] 为了更好的方便理解后面的具体实现,我提前放了一张目前小打卡的架构图 首先很熟悉的server这一边垫了一个数据层,主要将数据层和业务层解耦,提高复用性,并且提供一些通用功能,比如返回格式化数据问题,参数校验,日志监控... 在App对象和业务层同样增加了一个全局模块,提供独立于业务和工具类,只提供api之间双向通讯的渠道 工具模块的话其实就是对业务层的增强,比如常见的请求模块,上传模块,路由拦截等等 业务模块的话基本除了增加Component和中间层外没有太大变化 这个图上可能有两块可能大家觉得比较怪异,一个是global里面的函数重载,还有一个是业务模块的中间层是什么? 函数重载其实就是修改微信提供的App, Page, Component函数,使其更符合我们的业务场景, 业务模块的中间层就是依赖于函数重载的扩展 其实小打卡的整套架构都是基于这两个模块,这两个模块赋予了更多的可能性,然而实现却十分的简单 点击查看:小打卡 | 如何基于微信原生构建应用级小程序底层架构(下)
2019-04-22 - 如何通过小程序实现跨平台开发
背景 前段时间要做一系列的测试工具,需要在多平台: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 - Omi 6.0 - Store 的设计哲学
写在前面 Store 是 Omi 内置的中心化数据仓库,他解决了下面两个问题: 组件树数据共享 数据变更按需更新依赖的组件 [图片] 而这一切都是在运行时搞定。 一段代码完全上手 Store [代码]import { render, WeElement, define } from 'omi' define('my-counter', class extends WeElement { static use = [ { count: 'count' } ] add = () => this.store.add() sub = () => this.store.sub() addIfOdd = () => { if (this.use.count % 2 !== 0) { this.store.add() } } addAsync = () => { setTimeout(() => this.store.add(), 1000) } render() { return ( <p> Clicked: {this.use.count} times {' '} <button onClick={this.add}>+</button> {' '} <button onClick={this.sub}>-</button> {' '} <button onClick={this.addIfOdd}> Add if odd </button> {' '} <button onClick={this.addAsync}> Add async </button> </p> ) } }) render(<my-counter />, 'body', { data: { count: 0 }, sub() { this.data.count-- }, add() { this.data.count++ }, }) [代码] 这是一个简单的例子,说明了 store 体系的基本用法: 通过 [代码]static use[代码] 声明依赖的 path [代码]store[代码] 通过 render 的第三个参数从根节点注入到所有组件。 调用组件的方法或者直接改变组件的 data 进行视图更新 这里在书写过程中会出现两种方式,一种是将所有数据和逻辑放在 store 里,一种是将部分共享数据放在 store 里,这里没有强制要求使用哪种方式,omi 这两种能力都有,开发者偏爱哪种方式就使用哪种方式。 复杂的例子 Store 里的 data: [代码]{ count: 0, arr: ['china', 'tencent'], motto: 'I love omi.', userInfo: { firstName: 'dnt', lastName: 'zhang', age: 18 } } [代码] Static use: [代码]static use = [ 'count', //直接字符串,可通过 this.use[0] 访问 'arr[0]', //也支持 path,可通过 this.use[1] 访问 //支持 json { //alias,可通过 this.use.reverseMotto 访问 reverseMotto: [ 'motto', //path target => target.split('').reverse().join('') //computed ] }, { name: 'arr[1]' }, //{ alias: path },可通过 this.use.name 访问 { //alias,可通过 this.use.fullName 访问 fullName: [ ['userInfo.firstName', 'userInfo.lastName'], //path array (firstName, lastName) => firstName + lastName //computed ] }, ] [代码] 下面看看 JSX 中使用: [代码]... ... render() { return ( <div> <button onClick={this.sub}>-</button> <span>{this.use[0]}</span> <button onClick={this.add}>+</button> <div> <span>{this.use[1]}</span> <button onClick={this.rename}>rename</button> </div> <div>{this.use.reverseMotto}</div><button onClick={this.changeMotto}>change motto</button> <div>{this.use.name}</div> <div>{this.use[3]}</div> <div> {this.use.fullName} <button onClick={this.changeFirstName}>change first name</button> </div> </div> ) } ... ... [代码] 如果不带有 alias ,你也可以直接通过 [代码]this.store.data.xxx[代码] 访问。 Path 命中规则 当 [代码]store.data[代码] 发生变化,依赖变更数据的组件会进行更新,举例说明 Path 命中规则: Proxy Path(由数据更改产生) static use 中的 path 是否更新 abc abc 更新 abc[1] abc 更新 abc.a abc 更新 abc abc.a 不更新 abc abc[1] 不更新 abc abc[1].c 不更新 abc.b abc.b 更新 以上只要命中一个条件就可以进行更新! 总结: 只要注入组件的 path 等于 use 里声明 或者在 use 里声明的其中 path 子节点下就会进行更新! 解构赋值 [代码]import { define, WeElement } from 'omi' import '../my-list' define('my-sidebar', class extends WeElement { static css = require('./_index.css') static use = [ 'menus', 'sideBarShow', 'lan' ] render() { const [menus, sideBarShow, lan] = this.use return ( <div class={`list${sideBarShow ? ' show' : ''}`}> {menus[lan].map((menu, index) => ( <my-list menu={menu} index={index} /> ))} </div> ) } }) [代码] 这里举了个例子使用 ES2015+ 语法 [代码]const [xx, xxx] = xxxx[代码] 的语法快速赋值。上面是从 omi docs 的源码里截取的部分。感兴趣的可以看看源码。omi 官网已经使用 omi 6.0 重写了。 设计哲学 回顾 Omi 从 1.0 到 6.0: Omi 1.0 运行时动态模板引擎 Omi 2.0 拥抱虚拟 DOM 和运行时 scoped style Omi 3.0 提供 native 模块调用 bridge Omi 4.0 拥抱 Web Components Omi 5.0 纠正社区对 MVVM 误解 Omi 6.0 拥抱多端统一,迎来全新 path updating 的 store 体系 1.0 使用的动态模板引擎,是图灵完备的,可以表达一切你想表达的结构。由于是运行时,没法转虚拟 DOM,一定要转也可以,开销大,所以缺点很明显,视图更新开销大,依赖真实 DOM 之间的的 diff,另外一个缺点就是动态模板引擎(指令、模板语法都可以动态拼接)需要在脑海里二次转换,书写起来不够直观、智能提示也没有,不如 JSX 直接干净和智能。而为什么要这么设计,从整个发展历程来看离不开三个字: 运行时。 Omi 的设计 1.0 败也败在运行时,成也成在运行时。从 2.0 开始,除了 JSX 的部分(当然可以直接 hyperscript),其余全部 运行时 搞定: 运行时的 scoped style 运行时的 path updating 局部刷新 而到了 9210 年,JSX 也出现了运行时的替代方案:htm 。 为何如此偏爱运行时?而不交给编译器去做?这个仁者见仁,智者见智,而且有个权衡在里面。 当运行时的开销对于用户体验可以忽略不计,那么就选择运行时去做 运行时的好处非常明显,不需要任何构建工具、编译工具,就可以在浏览器、node、javascript core 或者任何 javascript 环境直接运行。凭什么让我学那么多构建工具、凭什么和一堆工具耦合在一起,我就是纯粹的 js,想在哪里跑都可以轻松复制粘贴或者直接 import/require 过去,而不强制带上任何工具。当然这里不是反对编译工具对前端带来的价值,omi-cli、omip、omi-mp 都大量使用了编译工具,只是没有编译工具,omi 也能运行良好,简单移植,比如 es module,比如 deno,直接 import 直接使用。 开始使用 omijs.org https://github.com/Tencent/omi
2019-03-25 - 小程序自定义组件知多少
自定义组件 why 代码的复用 在起初小程序只支持 Page 的时候,就会有这样蛋疼的问题:多个页面有相同的组件,每个页面都要复制粘贴一遍,每次改动都要全局搜索一遍,还说不准哪里改漏了就出翔了。 组件化设计 在前端项目中,组件化是很常见的方式,某块通用能力的抽象和设计,是一个必备的技能。组件的管理、数据的管理、应用状态的管理,这些在我们设计的过程中都是需要去思考的。当然你也可以说我就堆代码就好了,不过一个真正的码农是不允许自己这么随便的! 所以,组件化是现代前端必须掌握的生存技能! 自定义组件的实现 一切都从 Virtual DOM 说起 前面《解剖小程序的 setData》有讲过,基于小程序的双线程设计,视图层(Webview 线程)和逻辑层(JS 线程)之间通信(表现为 setData),是基于虚拟 DOM 来实现数据通信和模版更新的。 自定义组件一样的双线程,所以一样滴基于 Virtual DOM 来实现通信。那在这里,Virtual DOM 的一些基本知识(包括生成 VD 对象、Diff 更新等),就不过多介绍啦~ Shadow DOM 模型 基于 Virtual DOM,我们知道在这样的设计里,需要一个框架来支撑维护整个页面的节点树相关信息,包括节点的属性、事件绑定等。在小程序里,Exparser 承担了这个角色。 前面《关于小程序的基础库》也讲过,Exparser 的主要特点包括: 基于 Shadow DOM 模型 可在纯 JS 环境中运行 Shadow DOM 是什么呢,它就是我们在写代码时候写的自定义组件、内置组件、原生组件等。Shadow DOM 为 Web 组件中的 DOM 和 CSS 提供了封装。Shadow DOM 使得这些东西与主文档的 DOM 保持分离。 简而言之,Shadow DOM 是一个 HTML 的新规范,其允许开发者封装 HTML 组件(类似 vue 组件,将 html,css,js 独立部分提取)。 例如我们定义了一个自定义组件叫[代码]<my-component>[代码],你在开发者工具可以见到: [图片] [代码]#shadow-root[代码]称为影子根,DOM 子树的根节点,和文档的主要 DOM 树分开渲染。可以看到它在[代码]<my-component>[代码]里面,换句话说,[代码]#shadow-root[代码]寄生在[代码]<my-component>[代码]上。[代码]#shadow-root[代码]可以嵌套,形成节点树,即称为影子树(Shadow Tree)。 像这样: [图片] Shadow Tree 拼接 既然组件是基于 Shadow DOM,那组件的嵌套关系,其实也就是 Shadow DOM 的嵌套,也可称为 Shadow Tree 的拼接。 Shadow Tree 拼接是怎么做的呢?一切又得从模版引擎讲起。 我们知道,Virtual DOM 机制会将节点解析成一个对象,那这个对象要怎么生成真正的 DOM 节点呢?数据变更又是怎么更新到界面的呢?这大概就是模版引擎做的事情了。 《前端模板引擎》里有详细描述模版引擎的机制,通常来说主要有这些: DOM 节点的创建和管理:[代码]appendChild[代码]/[代码]insertBefore[代码]/[代码]removeChild[代码]/[代码]replaceChild[代码]等 DOM 节点的关系(嵌套的处理):[代码]parentNode[代码]/[代码]childNodes[代码] 通常创建后的 DOM 节点会保存一个映射,在更新的时候取到映射,然后进行处理(通常包括替换节点、改变内容[代码]innerHTML[代码]、移动删除新增节点、修改节点属性[代码]setAttribute[代码]) 在上面的图我们也可以看到,在 Shadow Tree 拼接的过程中,有些节点并不会最终生成 DOM 节点,例如[代码]<slot>[代码]这种。 但是,常用的前端模版引擎,能直接用在小程序里吗? 双线程的难题 自定义组件渲染流程 双线程的设计,给小程序带来了很多便利,安全性管控力都拥有了,当然什么鬼东西都可以比作一把双刃剑,双线程也不例外。 我们知道,小程序分为 Webview 和 JS 双线程,逻辑层里是没法拿到真正的 DOM 节点,也没法随便动态变更页面的。那在这种情况下,我们要怎么去使用映射来更新模版呢(因为我们压根拿不到 Webview 节点的映射)? 所以在双线程下,其实两个线程都需要保存一份节点信息。这份节点信息怎么来的呢?其实就是我们需要在创建组件的时候,通过事件通知的方式,分别在逻辑层和视图层创建一份节点信息。 同时,视图层里的组件是有层级关系的,但是 JS 里没有怎么办?为了维护好父子嵌套等节点关系,所以我们在 逻辑层也需要维护一棵 Shadow Tree。 那么我们自定义组件的渲染流程大概是: 组件创建。 逻辑层:先是 wxml + js 生成一个 JS 对象(因为需要访问组件实例 this 呀),然后是 JS 其中节点部分生成 Virtual DOM,拼接 Shadow Tree 什么的,最后通过底层通信通知到 视图层 视图层:拿到节点信息,然后吭哧吭哧开始创建 Shadow DOM,拼接 Shadow Tree 什么的,最后生成真实 DOM,并保留下映射关系 组件更新。 这时候我们知道,不管是逻辑层,还是视图层,都维护了一份 Shadow Tree,要怎么保证他们之间保持一致呢? 让 JS 和 Webview 的组件保持一致 为了让两边的 Shadow Tree 保持一致,可以使用同步队列来传递信息。(这样就不会漏掉啦) 同步队列可以,每次变动我们就往队列里塞东西就好了。不过这样还会有个问题,我们也知道 setData 其实在实际项目里是使用比较频繁的,要是像 Component 的 observer 里做了 setData 这类型的操作,那不是每次变动会导致一大堆的 setDate?这样通信效率会很低吧? 所以,其实可以把一次操作里的所有 setData 都整到一次通信里,通过排序保证好顺序就好啦。 Page 和 Component Component 是 Page 的超集 事实上,小程序的页面也可以视为自定义组件。因而,页面也可以使用[代码]Component[代码]构造器构造,拥有与普通组件一样的定义段与实例方法。但此时要求对应 json 文件中包含[代码]usingComponents[代码]定义段。 来自官方文档-Component 所以,基于 Component 是 Page 的超集,那么其实组件的渲染流程、方式,其实跟页面没多大区别,应该可以一个方式去理解就差不多啦。 页面渲染 既然页面就是组件,那其实页面的渲染流程跟组件的渲染流程基本保持一致。 视图层渲染,可以参考7.4 视图层渲染说明。 结束语 其实很多新框架新工具出来的时候,经常会让人眼前一亮,觉得哇好厉害,哇好高大上。 但其实更多时候,我们需要挖掘新事物的核心,其实大多数都是在原有的事物上增加了个新视角,从不一样的视角看,看到的就不一样了呢。作为一名码农,我们要看到不变的共性,变化的趋势。
2019-04-01 - docker快速入门
1. 介绍docker是什么Docker使用go基于linux lxc(linux containers)技术实现的开源容器,诞生于2013年年初,最开始叫dotcloud公司,13年年底改名为docker inc。 2017年下载次数达到了百亿次,估值达13亿美元,通过对应用封装(Packaging)、分发(Distribution)、部署(Deployment)、运行(Runtime)全生命周期管理,达到“一次封装,到处运行” [图片] 为何使用docker?Docker直译码头工人,将各种大小和形状的物品装进船里。这对从事软件行业的人来说,听起来很熟悉,花了大量时间和精力把一个应用放在另一个应用里 [图片] docker出现之前,对不同环境的安装、配置、维护工作量很多,如部署,配置文件,crontab,依赖等等。 使用docker,无需关心环境,只需要一些配置就能构建镜像,而部署则用一条run命令 [图片] 虚拟机 vs 容器 虚拟机需要有额外的虚拟机管理应用和虚拟机操作系统层,操作系统层不仅占用空间而且运行速度也相对慢 docker容器是在本机操作系统层面上实现虚拟化,因此很轻量,速度接近原生系统速度 [图片] 虚拟机启动速度是分钟级别,性能较弱、内存和硬盘占用大,一个物理机最多跑几十个虚拟机,但它的隔离性比较好。 docker启停都是秒级实现,内存和硬盘占用非常小,单机支持上千个容器,在ibm服务器上可运行上万个容器 容器跟虚机相比,有着巨大的优势 [图片] docker优点 只关心应用:以往我们需要关心操作系统、软件、项目,有了docker我们可以只关心应用而不是操作系统,docker发展迅速,基于docker的paas平台也层出不穷,使得我们能更方便的使用docker 快速交付:docker可在秒级提供沙箱环境,开发,测试,运维使用完全相同的环境来部署代码 微服务:docker有助于将一个复杂系统分解,让用户用更离散的方式思考服务 离线开发:将服务编排在笔记本中移动办公,使用docker可在本机秒级别启动一个本地开发环境 降低调试成本:在测试和上线时产生无效的类、有问题的依赖、缺少的配置等问题,docker可让一个问题调试和环境重现变得更简单 CD:docker让持续交付实现变得更容易,特别是对于蓝绿部署就更简单。 第一版上线时,需要上第二版新功能,两个版本功能会有冲突,这时用docker实现蓝绿部署就非常方便了 如:可以部署两个版本同时在线,新版本测试没问题了把老版本流量切到新版本就可以了 迁移:可以很快的迁移到其他云或服务器 与传统虚拟机方式相比,容器化方式在很多场景下都是存在极为明显的优势。无论是开发、测试、运维都应该尽快掌握docker,尽早享受其带来的巨大便利 [图片] 容器化方式在很多场景下都有极大的优势。无论是开发、测试、运维都应该尽快掌握docker,尽早享受其带来的巨大便利 [图片] 概念再来了解docker非常关键的概念,这样才能理解docker容器整个生命周期 [图片] 概念—镜像 镜像(类)=文件系统+数据,我常常用开发语言中的类比作镜像,对象比作容器 镜像由多个层加上一些docker元数据组成,容器运行着由镜像定义的系统 [图片] 概念—容器容器(对象)=镜像运行实例 容器是镜像的运行实例,可以使用同一个镜像运行多个实例。如图所示,一个ubuntu docker镜像产生了三个ubuntu容器,docker利用容器运行和隔离应用 [图片] 从读写角度来说,镜像是只读的,容器是在镜像上添加了一层可读写的文件系统 [图片] [图片] 概念—层层=文件变更集合 像传统虚机应用,每个应用都需要拷贝一份文件副本,运行成百上千上磁盘空间会迅速耗光,而docker采用写时复制来减少磁盘空间,当一个运行中的容器要写入一个文件时,它会把该文件复制到新区域来记录这次的修改,在执行docker提交时将这次修改记录下并产生一个新的层。docker分层解决大规模使用容器时碰到的磁盘和效率问题 [图片] 概念—仓库docker借鉴了大量git优秀的经验。docker仓库分公有库和私有库,最大的公开仓库是docker hub,国内也有很多仓库源 [图片] 2. 创建第一个docker应用通过创建一个docker应用来看看docker是怎么方便使用的 创建docker镜像方式 创建docker有四种方式 [图片] 但最常用的docker命令+手工提交和Dockerfile的方式 [图片] 对于我们来说Dockerfile是最常用也是最有用的 “dockerfile” [图片] 那创建一个docker应用只需要三步:编写dockerfile、构建镜像、运行容器 编写dockerfile那我们就开始用dockerfile来创建一个应用 Dockerfile是包含一系列命令的文本文件,这个文件包含6条命令 1、FROM是使用php官方镜像,左边是镜像名字,右边是标签名字,标签名字不写默认是latest 2、声明维护人员 3、RUN运行一条linux命令,我们把php代码重定向到/tmp/index.php 4、EXPOSE声明要开放的端口 5、WORKDIR启动容器后默认目录 6、CMD容器启动后,默认执行的命令,相当于应用的入口,用php自带的webserver监听8000 [图片] 构建镜像使用docker build命令生成镜像,—tag指定镜像的名字,左边是名字,右边是标签,最后有个.表示在当前目录查找Dockerfile 可以看到,每个命令都会有个输入输出,输入是命令,输出是给到层的id,所以,基本上每个命令都会产生一个层 最后提示镜像构建成功,并打上镜像标签 [图片] 运行容器第三,使用docker run命令运行镜像,-p将容器的8000端口映射到本机8000端口,—name给容器起个名字 用curl对本机8000端口请求,服务器返回当前时间,说明我们构建的容器运行成功了 [图片] 请求本地8000端口,服务器返回当前时间 [图片] dockerfile常用命令其实Dockerfile常用命令就5个:from、add、run、workdir、cmd 创建docker应用步骤编写dockerfile 构建镜像 运行容器 使用docker应用步骤拉取镜像 运行容器 dockerfile最佳实践精简镜像用途 尽量让每个镜像的用途单一 选择合适基础镜像 选择以alpine、busybox等基础的镜像 busybox:号称操作系统里的瑞士军刀,只有……这么大,但却有一百多常用命令 如果你的目标是小而精,busybox是首选,因为它已经精简到没有bash,使用的是ash,一个兼容posix的shell [图片] Alpine:你的目标是小但是又有一些工具的话,可以选择alpine,它是一个面向安全的轻量linux发行版,它关注安全、性能和资源效能,比busybox功能更完善,还提供apk查询和安装软件包,大小只有2-3兆 [图片] 很多官方的镜像都有alpine的镜像,像刚刚使用的php镜像 [图片] 提供维护者信息 正确使用版本 使用明确的版本号,而非依赖于默认的latest,避免环境不一致导致的问题 [图片] 删除临时文件 如安装软件后的安装包,如上图2、3步骤 提高生成速度 如内容不变的指令尽量放在前面,这样可以复用 减少镜像层数 多条命令写在一起,使生成的镜像层数少,如上图2、3步骤 恰当使用multi-stage 保证最终生成镜像最小化 3. 常用命令search想使用一个镜像,用这个命令就可以了,默认按评分排序 official如果是ok表示是官方镜像 Auto标示它是否用dickerfile进行自动化镜像构建 [图片] pull一旦确定一个镜像,通过对其名称执行docker pull来下载 标签默认是latest,严格来讲,镜像的仓库名还应该添加仓库地址的,默认是registry.hub.docker.com Docker images命令查找下载的镜像 [图片] run使用docker run运行一个容器,it表示用交互式方式运行,最后表示要执行的命令 [图片] 其实更常用的方式是以后台方式来执行,这时用d参数在后台运行 运行后用exec命令进去到容器 [图片] tagDocker tag给镜像一个新tag名字 Docker images查看centos镜像,把centos:latest打上centos:yeedomliu,这时再看会有3个centos,latest和yeedomliu的镜像id是相同的 把centos:yeedomliu删除,再查看latest还会存在,最后用rmi命令删除latest就会真正把latest镜像删除掉 如果相同镜像存在多个标签,只有最后一次的rmi命令会真正删除镜像 [图片] psPs可以查看运行中的容器 [图片] rmi删除一个镜像,同一个镜像id的不同标签的镜像,使用rmi删除最后一个镜像才会真正删除这个镜像 [图片] rm删除docker容器,如果运行中的容器需要加-f [图片] diff容器启动后文件变化情况 [图片] logs查看容器运行后的日志 [图片] cp我们想从容器里面拷贝文件到宿主机,或相反的过程就可以用到cp命令 [图片] container prune随着使用docker时间越长,停止状态下的容器会越来越多,这些都会占据磁盘空间 [图片] image prune未被打标签的镜像可以用image prune命令清理 [图片] system prune/df如果你觉得刚刚两条命令执行起来麻烦,可以用docker system prune一条命令搞定 另外用system df查看docker磁盘空间 [图片] 4. 实战了解了docker基础知识后,可进入相对实战的环节 本地开发 常见问题 架构 优化 本地开发 我们的项目使用了很多服务,如redis/mysql/mongodb等等,如果一个个运行起来,还加上配置,容易出手,也比较麻烦 kitematic:与使用命令行管理本地容器相比,你更想使用图形工具对容器管理,官方推出的容器管理工具,通过它可以查找镜像、创建容器、配置、启停容器等管理 [图片] [图片] 这是配置容器端口和宿主机端口,目录,网络等映射界面 [图片] docker-composecompose定位是“定义和运行多个docker容器的应用”,前身fig,目前仍然兼容fig格式的模板文件。 一条命令可以把一个复杂的应用启动起来 日常工作中,经常碰到多个容器相互完成某项任务 [图片] docker-compose示例1 默认模板文件名叫docker-compose.yml,结构很简单,每个顶级元素为服务名称,次级信息为配置信息 这里使用了redis/mongodb/mysql/nginx镜像,分别给它们映射了本地目录、端口、密码等信息,nginx镜像需要使用redis/mysql等服务,用links命令连接进来 [图片] docker-compose示例2 如果在本地开发,每个项目都可以像之前说的那样配置,这里提供了另外一种做法 我把公共的资源在一开始就启动,每个项目里只启动nginx镜像并关联其它的服务即可 公共服务compose [图片] 项目compose [图片] 常见问题 主进程:docker启动第一个进程称主进程,就是id为1的进程,这个进程退出就意味着容器退出,所以想要使docker作为服务使用,这个进程是不能退出的 expose命令是声明暴露的端口,运行时用-P才会生效。一般ports命令是做真正的端口映射,比较常用 架构 安装了docker的主机,一般在一个私有网络上 1、调用docker客户端可以从守护进程获取信息或发送指令 2、docker守护进程使用http协议接收来自docker客户端的请求 3、私有docker注册中心存储docker镜像 4、docker hub是由docker公司运营的最大的公共注册中心 互联网上也存在其他公共的注册中心 调用 Docker客户端可以从守护进程获取信息或给它发送指令。守护进程是一个服务器,它使用 HTTP协议接收来自客户端的请求并返回响应。相应地,它会向其他服务发起请求来发送和接收镜像,使用的同样是 HTTP协议。该服务器将接收来自命令行客户端或被授权连接的任何人的请求。守护进程还负责在幕后处理用户的镜像和容器,而客户端充当的是用户与 REST风格 API之间的媒介。 理解这张图的关键在于,当用户在自己的机器上运行 Docker时,与其进行交互的可能是自己机器上的另一个进程,或者甚至是运行在内部网络或互联网上的服务。 [图片] 优化 使用小镜像:一般来说,使用小的镜像都相对比较优秀,如官方的镜像基本上都有基于alpine的镜像 事后清理:删除镜像里软件包或一些临时文件,减小镜像大小 命令写一行:多个命令尽量写在一起有助于减少层数,也会减少镜像的大小 脚本安装:使用脚本进行初始化时,可以有效减少dockerfile的命令,同时带来另外的问题,可读性不好并且构建镜像时缓存不了 扁平化镜像:构建镜像过程中,可能会涉及到一些敏感信息,或者用了上面的办法镜像依然很大,可以试试这个办法 docker export 容器名或容器id | docker import - 镜像标签 multi-stage:从docker 17.05版本开始,docker支持multi-stage(多阶段构建),特别适合编译型语言,如我在一个镜像下编译,在另外一个很小的系统运行,如下图,go项目在golang环境下编译,在alpine环境下运行 [图片]
2019-03-27 - 爬虫vs反爬虫
爬虫介绍 爬虫简单介绍就是一个获取数据的途径。有时我们需要进行数据分析等操作,都会将别人网站中现成的数据放入我们自己本地数据库内,这时候,我们可以使用爬虫来实现。 网站的重要资料、信息财产被轻易窃取,是不能随便泄漏的。我们就应该使用反爬虫技术。本文将依次先将常见的反爬虫技术,与对应的爬虫技巧。 爬虫原理 一般我们访问网络资源都是通过uri。我们要获取的信息,一般有两种常见形式。json或html。html一般是后端服务器渲染后返回的,json是服务器直接返回给前端,然后前端自己在将数据渲染到页面上。如果是json类型的话,可以直接请求这个uri,或者等待前端渲染完毕再从html获取。获取到html信息后,通过dom操作,即可获得对应内容。爬取手机app的内容时,需要使用抓包工具:Fiddler,Charles,Wireshark。 最简单的爬虫,用shell就可以实现。复杂的爬虫,甚至需要用到机器学习分析。 注意:大部分情况爬虫是没有法律问题的,只有网站明确声明了禁止使用网络爬虫和转载商业化时(付费知识)时,爬虫才会触犯法律 为了更快的创建一只爬虫,请先安装Postman,Chrome等软件,用于发送请求,并可以查看详细请求和响应头部和内容 如果有反爬虫需要,请一定要记录服务器请求日志。分析日志才能找出潜在爬虫,即使没有及时对应的解决的方案,也可以暂时将其拉入黑名单。 爬虫流程 [图片] 整体流程的关键在于将网上的数据通过机器方式自动获得。没有反爬虫机制的 uri,还可以使用分布式爬虫,开启多线程,快速爬取信息。遇到了反爬虫机制,一般来说用上对应的反反爬虫方法就行了。大部分网站仅仅是对游客有访问限制,如果不想注册账号,基本都可以使用代理方式解决。也可以在登录之后复制登录的完整请求头解决。但是对于某些数据会有验证码拦截。这时我们就要将网站使用的验证码分类并在网上找到对应的解决方案。 查看请求 使用Chrom浏览器(也可以用别的,这里只用Chrome做例子)访问我们要爬取的页面。通过F12进入开发者工具,切换到network面板下(没有数据,请刷新再次请求) 找到要获取数据的URI [图片] 请求所携带的请求头都在这里,完整附带请求信息,可以充分模拟浏览器。 [图片] 直接通过Chrome将请求信息导出,在自己的终端下尝试请求。 由于Google页面携带的cookie内容较多,而且hk站点下编码不是常见的UTF8格式,会出现乱码,这里就以百度为例。 [代码]curl 'https://www.baidu.com/' -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:67.0) Gecko/20100101 Firefox/67.0' -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' -H 'Accept-Language: en-US,en;q=0.5' --compressed -H 'DNT: 1' -H 'Connection: keep-alive' -H 'Cookie: BAIDUID=606890F9A814F1194EEA6EC7D743CC84:FG=1; BIDUPSID=606890F9A814F1194EEA6EC7D743CC84; PSTM=1553581103; delPer=0; BD_HOME=0; H_PS_PSSID=26524_1461_21106_28722_28557_28697_28584_26350_28604_28606; BD_UPN=133252; BDORZ=B490B5EBF6F3CD402E515D22BCDA1598' -H 'Upgrade-Insecure-Requests: 1' -H 'Pragma: no-cache' -H 'Cache-Control: no-cache' [代码] 添加-o 将本次请求的response保存为文件。某些页面可能会返回乱码,是因为开启了gzip或者编码需要重新设置,部分网页编码非UTF8。 直接访问 这种网站一般数据都是完全公开的,可以算是0反爬虫。对于这种网站,直接请求即可。这种爬虫最好做,所以一般搜索引擎也最好搜索。很多网站即使内部有许多反爬虫机制,但是首页还是会为了SEO,一般不会反爬虫。 鉴别浏览器 通过浏览器进行网站访问时,都会携带user-agent信息。而在本地请求时,并不会携带这些信息。同时浏览器会保持一个session会话,而一般的request模块中,并不能携带session。我们可以通过session存放标记或者判断header部分,来鉴别爬虫。 爬虫方案 对于session的判断,使用带session的模块。比如python的requests或者使用headless。 对于header的判断,可以在浏览器的请求内复制完整的请求头。或者直接使用headless 判断token token一般不用于自己的主站。访问过很多网站,我只见过掘金在使用。token的目的不是反爬虫,而是防止用户的账号密码泄露。 先简单讲讲token步骤 客户端第一次登录 服务器确认登录并返回有期限的合法token 客服端进行请求时携带token 服务器判断token识别合法用户,然后在执行请求 爬虫方案 一般服务器都是使用sesssion的,但如果确实使用了token的。我们可以分析接口,获取token,并在请求时携带token即可。 限制ip访问频率 一般而言限制ip访问,不只是为了减少爬虫的数量,更是为了防止DDOS之类攻击。因为同一ip的大量访问,将会占用服务器的大量资源。如果触发的还是一个费时的操作,将会导致服务器来不及处理其他正常用户的请求。可以直接通过nginx自动让网站都有一个拦截ip频繁访问。 其实大部分网站还是会设置一定的阈值的,可能是1小时的访问次数,又或者每日,每个游客用户的访问次数。 爬虫方案 通过添加定时器:随机几秒延迟,不超过服务器的阈值,简单模拟人的访问频率。 使用代理服务器:大部分语言的请求,都提供了proxy的api。使用代理后,就可避开ip的限制了。所以我们反爬虫时尽力用非ip方式判断是不是用了proxy在爬取服务器 使用网站登录信息:部分网站是对游客有所限制,对于登录的用户会有更多访问次数。所以可以利用这点提高频率。 验证码 验证码一直是一个反人类的玩意。但是他设计的目的是为了反机器。验证码和破解验证码有着很长的战争历史。大部分后端语言都有快速部署数字验证码的组件。 爬虫方案 最简单的方破解法就是使用登录信息或者用代理更换ip,不过部分情况还是会遇到不可避免的验证码的。 使用第三方平台接入,手工在线解验证码。 使用机器学习配合headless,破解验证码 滑动验证码 [图片] 现在经常出现的滑动验证码,使用的是极验提供的验证码。通过滑动确实让用户的操作成本降低了。 爬虫方案 网上出现了不少的破解案例。这里推荐使用headless将通过代码能让鼠标执行操作验证码。 图片文字验证码 [图片] 爬虫方案 这种验证码比上面的要求更高,提高的用户的成本。使用的比较少,但是破解方法还是有的,就是使用OCR或者机器学习。 前端动态渲染 现代前端日益强大,许多事情在前端都可以完成。为了减轻服务器的压力。有些网站已经从传统的JSP,PHP渲染转为,Java,PHP提供接口,由前端自行渲染。 爬虫方案 这时候,我们可以再次在Network面板中查看获取信息的api接口。但是部分时候,数据会比较复杂,晦涩。甚至还有加密信息在返回的数据之内。其实我们也可能让数据直接在网页上自己渲染出来,就是通过headless等无头浏览器。实现 图片代替文字,字体映射 这种反爬方案比较高级,单一般用户体验也不会很高,成本也不低。遇到还是换个网站比较好。 headless无头浏览器 Node.js Python Puppeteer、phantomjs、Splash Selenium 更多headless 使用headless基本上算是终极解决方案了,用代码的方式去执行一个no GUI的浏览器。包括鼠标的移动,点击,拖动等。而且还能自动携带Session和Cookie信息,不过headless设计的初衷其实是前端自动化测试… [代码]const puppeteer = require('puppeteer'); async function getPage() { //创建实例 const browser = await puppeteer.launch(); //新建页面 const page = await browser.newPage(); await page.goto("https://juejin.im"); //等待1秒 await page.waitFor(1000); //截屏 await page.screenshot({path: `preview.png`}); //关闭实例 await browser.close(); } getPage(); [代码] 这段代码可以将SPA页面加载出来,查看效果可以使用api提供的screenshot截屏功能 [图片] 注意:使用headless需要加入渲染页面的性能,会导致爬虫性能极速下降(毕竟本来设计目的不是爬虫) 总结 其实为什么反爬虫没过多久都能被破解呢?其实主要原因是浏览器的用户信息的透明的,我们可以通过浏览器就可以看到开发的前端源代码。即使使用了各种技术,只要有前端,耐心的分析还是可以破解的。就算不行,我也可以通过headless确实以浏览器方式进行访问。即使是App也可以通过抓包,分析api,进行爬虫。 其实做反爬虫就好像是图灵测试,通过一系列的方法来辨别当前访问者是人还是机器。但是这个测试又不能使用户感到返感。
2019-03-26 - 小程序多端框架全面测评
最近前端届多端框架频出,相信很多有代码多端运行需求的开发者都会产生一些疑惑:这些框架都有什么优缺点?到底应该用哪个? 作为 Taro 开发团队一员,笔者想在本文尽量站在一个客观公正的角度去评价各个框架的选型和优劣。但宥于利益相关,本文的观点很可能是带有偏向性的,大家可以带着批判的眼光去看待,权当抛砖引玉。 那么,当我们在讨论多端框架时,我们在谈论什么: 多端 笔者以为,现在流行的多端框架可以大致分为三类: 1. 全包型 这类框架最大的特点就是从底层的渲染引擎、布局引擎,到中层的 DSL,再到上层的框架全部由自己开发,代表框架是 Qt 和 Flutter。这类框架优点非常明显:性能(的上限)高;各平台渲染结果一致。缺点也非常明显:需要完全重新学习 DSL(QML/Dart),以及难以适配中国特色的端:小程序。 这类框架是最原始也是最纯正的的多端开发框架,由于底层到上层每个环节都掌握在自己手里,也能最大可能地去保证开发和跨端体验一致。但它们的框架研发成本巨大,渲染引擎、布局引擎、DSL、上层框架每个部分都需要大量人力开发维护。 2. Web 技术型 这类框架把 Web 技术(JavaScript,CSS)带到移动开发中,自研布局引擎处理 CSS,使用 JavaScript 写业务逻辑,使用流行的前端框架作为 DSL,各端分别使用各自的原生组件渲染。代表框架是 React Native 和 Weex,这样做的优点有: 开发迅速 复用前端生态 易于学习上手,不管前端后端移动端,多多少少都会一点 JS、CSS 缺点有: 交互复杂时难以写出高性能的代码,这类框架的设计就必然导致 [代码]JS[代码] 和 [代码]Native[代码] 之间需要通信,类似于手势操作这样频繁地触发通信就很可能使得 UI 无法在 16ms 内及时绘制。React Native 有一些声明式的组件可以避免这个问题,但声明式的写法很难满足复杂交互的需求。 由于没有渲染引擎,使用各端的原生组件渲染,相同代码渲染的一致性没有第一种高。 3. JavaScript 编译型 这类框架就是我们这篇文章的主角们:[代码]Taro[代码]、[代码]WePY[代码] 、[代码]uni-app[代码] 、 [代码]mpvue[代码] 、 [代码]chameleon[代码],它们的原理也都大同小异:先以 JavaScript 作为基础选定一个 DSL 框架,以这个 DSL 框架为标准在各端分别编译为不同的代码,各端分别有一个运行时框架或兼容组件库保证代码正确运行。 这类框架最大优点和创造的最大原因就是小程序,因为第一第二种框架其实除了可以跨系统平台之外,也都能编译运行在浏览器中。(Qt 有 Qt for WebAssembly, Flutter 有 Hummingbird,React Native 有 [代码]react-native-web[代码], Weex 原生支持) 另外一个优点是在移动端一般会编译到 React Native/Weex,所以它们也都拥有 Web 技术型框架的优点。这看起来很美好,但实际上 React Native/Weex 的缺点编译型框架也无法避免。除此之外,编译型框架的抽象也不是免费的:当 bug 出现时,问题的根源可能出在运行时、编译时、组件库以及三者依赖的库等等各个方面。在 Taro 开源的过程中,我们就遇到过 Babel 的 bug,React Native 的 bug,JavaScript 引擎的 bug,当然也少不了 Taro 本身的 bug。相信其它原理相同的框架也无法避免这一问题。 但这并不意味着这类为了小程序而设计的多端框架就都不堪大用。首先现在各巨头超级 App 的小程序百花齐放,框架会为了抹平小程序做了许多工作,这些工作在大部分情况下是不需要开发者关心的。其次是许多业务类型并不需要复杂的逻辑和交互,没那么容易触发到框架底层依赖的 bug。 那么当你的业务适合选择编译型框架时,在笔者看来首先要考虑的就是选择 DSL 的起点。因为有多端需求业务通常都希望能快速开发,一个能够快速适应团队开发节奏的 DSL 就至关重要。不管是 React 还是 Vue(或者类 Vue)都有它们的优缺点,大家可以根据团队技术栈和偏好自行选择。 如果不管什么 DSL 都能接受,那就可以进入下一个环节: 生态 以下内容均以各框架现在(2019 年 3 月 11日)已发布稳定版为标准进行讨论。 开发工具 就开发工具而言 [代码]uni-app[代码] 应该是一骑绝尘,它的文档内容最为翔实丰富,还自带了 IDE 图形化开发工具,鼠标点点点就能编译测试发布。 其它的框架都是使用 CLI 命令行工具,但值得注意的是 [代码]chameleon[代码] 有独立的语法检查工具,[代码]Taro[代码] 则单独写了 ESLint 规则和规则集。 在语法支持方面,[代码]mpvue[代码]、[代码]uni-app[代码]、[代码]Taro[代码] 、[代码]WePY[代码] 均支持 TypeScript,四者也都能通过 [代码]typing[代码] 实现编辑器自动补全。除了 API 补全之外,得益于 TypeScript 对于 JSX 的良好支持,Taro 也能对组件进行自动补全。 CSS 方面,所有框架均支持 [代码]SASS[代码]、[代码]LESS[代码]、[代码]Stylus[代码],Taro 则多一个 [代码]CSS Modules[代码] 的支持。 所以这一轮比拼的结果应该是: [代码]uni-app[代码] > [代码]Taro[代码] > [代码]chameleon[代码] > [代码]WePY[代码]、[代码]mpvue[代码] [图片] 多端支持度 只从支持端的数量来看,[代码]Taro[代码] 和 [代码]uni-app[代码] 以六端略微领先(移动端、H5、微信小程序、百度小程序、支付宝小程序、头条小程序),[代码]chameleon[代码] 少了头条小程序紧随其后。 但值得一提的是 [代码]chameleon[代码] 有一套自研多态协议,编写多端代码的体验会好许多,可以说是一个能戳到多端开发痛点的功能。[代码]uni-app[代码] 则有一套独立的条件编译语法,这套语法能同时作用于 [代码]js[代码]、样式和模板文件。[代码]Taro[代码] 可以在业务逻辑中根据环境变量使用条件编译,也可以直接使用条件编译文件(类似 React Native 的方式)。 在移动端方面,[代码]uni-app[代码] 基于 [代码]weex[代码] 定制了一套 [代码]nvue[代码] 方案 弥补 [代码]weex[代码] API 的不足;[代码]Taro[代码] 则是暂时基于 [代码]expo[代码] 达到同样的效果;[代码]chameleon[代码] 在移动端则有一套 SDK 配合多端协议与原生语言通信。 H5 方面,[代码]chameleon[代码] 同样是由多态协议实现支持,[代码]uni-app[代码] 和 [代码]Taro[代码] 则是都在 H5 实现了一套兼容的组件库和 API。 [代码]mpvue[代码] 和 [代码]WePY[代码] 都提供了转换各端小程序的功能,但都没有 h5 和移动端的支持。 所以最后一轮对比的结果是: [代码]chameleon[代码] > [代码]Taro[代码]、[代码]uni-app[代码] > [代码]mpvue[代码]、[代码]WePY[代码] [图片] 组件库/工具库/demo 作为开源时间最长的框架,[代码]WePY[代码] 不管从 Demo,组件库数量 ,工具库来看都占有一定优势。 [代码]uni-app[代码] 则有自己的插件市场和 UI 库,如果算上收费的框架和插件比起 [代码]WePy[代码] 也是完全不遑多让的。 [代码]Taro[代码] 也有官方维护的跨端 UI 库 [代码]taro-ui[代码] ,另外在状态管理工具上也有非常丰富的选择(Redux、MobX、dva),但 demo 的数量不如前两个。但 [代码]Taro[代码] 有一个转换微信小程序代码为 Taro 代码的工具,可以弥补这一问题。 而 [代码]mpvue[代码] 没有官方维护的 UI 库,[代码]chameleon[代码] 第三方的 demo 和工具库也还基本没有。 所以这轮的排序是: [代码]WePY[代码] > [代码]uni-app[代码] 、[代码]taro[代码] > [代码]mpvue[代码] > [代码]chameleon[代码] [图片] 接入成本 接入成本有两个方面: 第一是框架接入原有微信小程序生态。由于目前微信小程序已呈一家独大之势,开源的组件和库(例如 [代码]wxparse[代码]、[代码]echart[代码]、[代码]zan-ui[代码] 等)多是基于原生微信小程序框架语法写成的。目前看来 [代码]uni-app[代码] 、[代码]Taro[代码]、[代码]mpvue[代码] 均有文档或 demo 在框架中直接使用原生小程序组件/库,[代码]WePY[代码] 由于运行机制的问题,很多情况需要小改一下目标库的源码,[代码]chameleon[代码] 则是提供了一个按步骤大改目标库源码的迁移方式。 第二是原有微信小程序项目部分接入框架重构。在这个方面 Taro 在京东购物小程序上进行了大胆的实践,具体可以查看文章《Taro 在京东购物小程序上的实践》。其它框架则没有提到相关内容。 而对于两种接入方式 Taro 都提供了 [代码]taro convert[代码] 功能,既可以将原有微信小程序项目转换为 Taro 多端代码,也可以将微信小程序生态的组件转换为 Taro 组件。 所以这轮的排序是: [代码]Taro[代码] > [代码]mpvue[代码] 、 [代码]uni-app[代码] > [代码]WePY[代码] > [代码]chameleon[代码] 流行度 从 GitHub 的 star 来看,[代码]mpvue[代码] 、[代码]Taro[代码]、[代码]WePY[代码] 的差距非常小。从 NPM 和 CNPM 的 CLI 工具下载量来看,是 Taro(3k/week)> mpvue (2k/w) > WePY (1k/w)。但发布时间也刚好反过来。笔者估计三家的流行程度和案例都差不太多。 [代码]uni-app[代码] 则号称有上万案例,但不像其它框架一样有一些大厂应用案例。另外从开发者的数量来看也是 [代码]uni-app[代码] 领先,它拥有 20+ 个 QQ 交流群(最大人数 2000)。 所以从流行程度来看应该是: [代码]uni-app[代码] > [代码]Taro[代码]、[代码]WePY[代码]、[代码]mpvue[代码] > [代码]chameleon[代码] [图片] 开源建设 一个开源作品能走多远是由框架维护团队和第三方开发者共同决定的。虽然开源建设不能具体地量化,但依然是衡量一个框架/库生命力的非常重要的标准。 从第三方贡献者数量来看,[代码]Taro[代码] 在这一方面领先,并且 [代码]Taro[代码] 的一些核心包/功能(MobX、CSS Modules、alias)也是由第三方开发者贡献的。除此之外,腾讯开源的 [代码]omi[代码] 框架小程序部分也是基于 Taro 完成的。 [代码]WePY[代码] 在腾讯开源计划的加持下在这一方面也有不错的表现;[代码]mpvue[代码] 由于停滞开发了很久就比较落后了;可能是产品策略的原因,[代码]uni-app[代码] 在开源建设上并不热心,甚至有些部分代码都没有开源;[代码]chameleon[代码] 刚刚开源不久,但它的代码和测试用例都非常规范,以后或许会有不错的表现。 那么这一轮的对比结果是: [代码]Taro[代码] > [代码]WePY[代码] > [代码]mpvue[代码] > [代码]chameleon[代码] > [代码]uni-app[代码] 最后补一个总的生态对比图表: [图片] 未来 从各框架已经公布的规划来看: [代码]WePY[代码] 已经发布了 [代码]v2.0.alpha[代码] 版本,虽然没有公开的文档可以查阅到 [代码]2.0[代码] 版本有什么新功能/特性,但据其作者介绍,[代码]WePY 2.0[代码] 会放大招,是一个「对得起开发者」的版本。笔者也非常期待 2.0 正式发布后 [代码]WePY[代码] 的表现。 [代码]mpvue[代码] 已经发布了 [代码]2.0[代码] 的版本,主要是更新了其它端小程序的支持。但从代码提交, issue 的回复/解决率来看,[代码]mpvue[代码] 要想在未来有作为首先要打消社区对于 [代码]mpvue[代码] 不管不顾不更新的质疑。 [代码]uni-app[代码] 已经在生态上建设得很好了,应该会在此基础之上继续稳步发展。如果 [代码]uni-app[代码] 能加强开源开放,再加强与大厂的合作,相信未来还能更上一层楼。 [代码]chameleon[代码] 的规划比较宏大,虽然是最后发的框架,但已经在规划或正在实现的功能有: 快应用和端拓展协议 通用组件库和垂直类组件库 面向研发的图形化开发工具 面向非研发的图形化页面搭建工具 如果 [代码]chameleon[代码] 把这些功能都做出来的话,再继续完善生态,争取更多第三方开发者,那么在未来 [代码]chameleon[代码] 将大有可为。 [代码]Taro[代码] 的未来也一样值得憧憬。Taro 即将要发布的 [代码]1.3[代码] 版本就会支持以下功能: 快应用支持 Taro Doctor,自动化检查项目配置和代码合法性 更多的 JSX 语法支持,1.3 之后限制生产力的语法只有 [代码]只能用 map 创造循环组件[代码] 一条 H5 打包体积大幅精简 同时 [代码]Taro[代码] 也正在对移动端进行大规模重构;开发图形化开发工具;开发组件/物料平台以及图形化页面搭建工具。 结语 那说了那么多,到底用哪个呢? 如果不介意尝鲜和学习 DSL 的话,完全可以尝试 [代码]WePY[代码] 2.0 和 [代码]chameleon[代码] ,一个是酝酿了很久的 2.0 全新升级,一个有专门针对多端开发的多态协议。 [代码]uni-app[代码] 和 [代码]Taro[代码] 相比起来就更像是「水桶型」框架,从工具、UI 库,开发体验、多端支持等各方面来看都没有明显的短板。而 [代码]mpvue[代码] 由于开发一度停滞,现在看来各个方面都不如在小程序端基于它的 [代码]uni-app[代码] 。 当然,Talk is cheap。如果对这个话题有更多兴趣的同学可以去 GitHub 另行研究,有空看代码,没空看提交: chameleon: https://github.com/didi/chameleon mpvue: https://github.com/Meituan-Dianping/mpvue Taro: https://github.com/NervJS/taro uni-app: https://github.com/dcloudio/uni-app WePY: https://github.com/Tencent/wepy (按字母顺序排序)
2019-03-19 - 模块化:Javascript模块化的演进
ES2015 在2015年6月正式发布,官方终于引入了对于模块的原生支持,如今 JS 的模块化开发非常的方便、自然,但这个新规范仅仅持续了3年。就在7年前,JS 的模块化还停留在运行时的支持;13年前,通过后端模版定义、注释定义模块依赖。对于经历过的人来说,历史的模块化方式还停留在脑海中,久久不能忘怀。 为什么需要模块化 模块,是为了完成某一功能所需的程序或者子程序。模块是系统中“职责单一”且“可替换”的部分。所谓的模块化就是指把系统代码分为一系列职责单一且可替换的模块。模块化开发是指如何开发新的模块和复用已有的模块来实现应用的功能。 那么,为什么需要模块化呢?主要是以下几个方面的原因: 传统的网页开发正在转变成 Web Apps 开发 代码复杂度在逐步增高,随着 Web 能力的增强,越来越多的业务逻辑和交互都放在 Web 层实现 开发者想要分离的JS文件/模块,便于后续代码的维护性 部署时希望把代码优化成几个 HTTP 请求 模块化的演进历程 直接定义依赖(1999) 最先尝试将模块化引入到 JS 中是在1999年,称作“直接定义依赖”。这种方式实现模块化简单粗暴 —— 通过全局方法定义、引用模块。Dojo 就是使用这种方式进行模块组织的,使用[代码]dojo.provide[代码] 定义一个模块,[代码]dojo.require[代码] 来调用在其它地方定义的模块。 我们在dojo 1.6中修改下示例: [代码]// greeting.js 文件 dojo.provide("app.greeting"); app.greeting.helloInLang = { en: 'Hello world!', es: '¡Hola mundo!', ru: 'Привет мир!' }; app.greeting.sayHello = function (lang) { return app.greeting.helloInLang[lang]; }; // hello.js 文件 dojo.provide("app.hello"); dojo.require('app.greeting'); app.hello = function(x) { document.write(app.greeting.sayHello('es')); }; [代码] 直接定义依赖的方式和commonjs非常类似,区别是它可以在任何文件中定义模块,模块不和文件进行关联。而在commonjs中,每一个文件即是一个模块。 namespace模式(2002) 最早,我们这么写代码: [代码]function foo() { } function bar() { } [代码] 很多变量和函数会直接在 global 全局作用域下面声明,很容易产生命名冲突。于是,命名空间模式被提出。比如: [代码] var app = {}; app.foo = function() { } app.bar = function() { } app.foo(); [代码] 匿名闭包 IIFE 模式(2003) 尽管 namespace 模式一定程度上解决了 global 命名空间上的变量污染问题,但是它没办法解决代码和数据隔离的问题。 解决这个问题的先驱是:匿名闭包 IIFE 模式。它最核心的思想是对数据和代码进行封装,并通过提供外部方法来对它们进行访问。一个基本的例子如下: [代码] var greeting = (function () { var module = {}; var helloInLang = { en: 'Hello world!', es: '¡Hola mundo!', ru: 'Привет мир!' }; module.getHello = function (lang) { return helloInLang[lang]; }; module.writeHello = function (lang) { document.write(module.getHello(lang)) }; return module; }()); [代码] 模板依赖定义(2006) 模板依赖定义是接下来的一个用于解决模块化问题的方案,它需要配合后端的模板语法一起使用,通过后端语法聚合 js 文件,从而实现依赖加载。 它最开始被应用到 Prototype 1.4 库中。05年的时候,Sam Stephenson 开始开发 Prototype 库,Prototype 当时是作为 Ruby on rails 的一个组成部分。由于 Sam 平时使用 Ruby 很多,这也难怪他会选择使用 Ruby 里的 erb 模板作为 JS 模块的依赖管理了。 模板依赖定义的具体做法是:对于某个 JS 文件而言,如果它依赖其它 JS 文件,则在这个文件的头部通过特殊的标签语法去指定以来。这些标签语法可以通过后端模板(erb, jinjia, smarty)去解析,和特殊的构建工具如 borshik 去识别。只得一提的是,这种模式只能作用于预编译阶段。 下面是一个使用了 borshik 的例子: [代码] // app.tmp.js 文件 /*borschik:include:../lib/main.js*/ /*borschik:include:../lib/helloInLang.js*/ /*borschik:include:../lib/writeHello.js*/ // main.js 文件 var app = {}; // helloInLang.js 文件 app.helloInLang = { en: 'Hello world!', es: '¡Hola mundo!', ru: 'Привет мир!' }; // writeHello.js 文件 app.writeHello = function (lang) { document.write(app.helloInLang[lang]); }; [代码] 注释定义依赖(2006) 注释定义依赖和1999年的直接定义依赖的做法非常类似,不同的是,注释定义依赖是以文件为单位定义模块了。模块之间的依赖关系通过注释语法进行定义。 一个应用如果想用这种方式,可以通过预编译的方式(Mootools),或者在运行期动态的解析下载下来的代码(LazyJS)。 [代码]// helloInLang.js 文件 var helloInLang = { en: 'Hello world!', es: '¡Hola mundo!', ru: 'Привет мир!' }; // sayHello.js 文件 /*! lazy require scripts/app/helloInLang.js */ function sayHello(lang) { return helloInLang[lang]; } // hello.js 文件 /*! lazy require scripts/app/sayHello.js */ document.write(sayHello('en')); [代码] 依赖注入(2009) 2004年,Martin Fowler为了描述Java里的组件之间的通信问题,提出了依赖注入概念。 五年之后,前Sun和Adobe员工Miško Hevery(JAVA程序员)开始为他的创业公司设计一个 JS 框架,这个框架主要使用依赖注入的思想却解决组件之间的通信问题。后来的结局大家都知道,他的项目被 Google 收购,Angular 成为最流行的 JS 框架之一。 [代码]// greeting.js 文件 angular.module('greeter', []) .value('greeting', { helloInLang: { en: 'Hello world!', es: '¡Hola mundo!', ru: 'Привет мир!' }, sayHello: function(lang) { return this.helloInLang[lang]; } }); // app.js 文件 angular.module('app', ['greeter']) .controller('GreetingController', ['$scope', 'greeting', function($scope, greeting) { $scope.phrase = greeting.sayHello('en'); }]); [代码] CommonJS模式 (2009) 09年 CommonJS(或者称作 CJS)规范推出,并且最后在 Node.js 中被实现。 [代码]// greeting.js 文件 var helloInLang = { en: 'Hello world!', es: '¡Hola mundo!', ru: 'Привет мир!' }; var sayHello = function (lang) { return helloInLang[lang]; } module.exports.sayHello = sayHello; // hello.js 文件 var sayHello = require('./lib/greeting').sayHello; var phrase = sayHello('en'); console.log(phrase); [代码] 这里我们发现,为了实现CommonJS规范,我们需要两个新的入口 – [代码]require[代码] 和 [代码]module[代码],它们提供加载模块和对外界暴露接口的能力。 值得注意的是,无论是 require 还是 module,它们都是语言的关键字。在 Node.js 中,由于是辅助功能,我们可以使用它。在将Node.js中的模块发送给 V8 之前,会通过下面的函数进行包裹: [代码](function (exports, require, module, __filename, __dirname) { // ... // 模块的代码在这里 // ... }); [代码] 就目前来看,CommonJS规范是最常见的模块格式规范。你不仅可以在 Node.js 中使用它,而且可以借助 Browserfiy 或 Webpack 在浏览器中使用。 AMD 模式(2009) CommonJS的工作在全力的推进,同时,关于模块的异步加载的讨论也越来越多。主要是希望解决 Web 应用的动态加载依赖,相比于 CommonJS,体积更小。 [代码]// lib/greeting.js 文件 define(function() { var helloInLang = { en: 'Hello world!', es: '¡Hola mundo!', ru: 'Привет мир!' }; return { sayHello: function (lang) { return helloInLang[lang]; } }; }); // hello.js 文件 define(['./lib/greeting'], function(greeting) { var phrase = greeting.sayHello('en'); document.write(phrase); }); [代码] 虽然 AMD 的模式很适合浏览器端的开发,但是随着 npm 包管理的机制越来越流行,这种方式可能会逐步的被淘汰掉。 ES2015 Modules(2015) ES2015 modules(简称 ES modules)就是我们目前所使用的模块化方案,它已经在Node.js 9里原生支持,可以通过启动加上flag [代码]--experimental-modules[代码]使用,不需要依赖 babel 等工具。目前还没有被浏览器实现,前端的项目可以使用 babel 或 typescript 提前体验。 [代码]// lib/greeting.js 文件 const helloInLang = { en: 'Hello world!', es: '¡Hola mundo!', ru: 'Привет мир!' }; export const greeting = { sayHello: function (lang) { return helloInLang[lang]; } }; // hello.js 文件 import { greeting } from "./lib/greeting"; const phrase = greeting.sayHello("en"); document.write(phrase); [代码] 总结 JS 模块化的演进历程让人感慨,感谢 TC 39 对 ES modules 的支持,让 JS 的模块化进程终于可以告一段落了,希望几年后所有的主流浏览器可以原生支持 ES modules。 JS 模块化的演进另一方面也说明了 Web 的能力在不断增强,Web 应用日趋复杂。相信未来 JS 的能力进一步提升,我们的开发效率也会更加高效。
2019-03-21 - JavaScript 函数式编程(三)
slide 地址 四、Talk is cheap!Show me the … MONEY! 以下内容主要参考自 Professor Frisby Introduces Composable Functional JavaScript [图片] 4.1.容器(Box) 假设有个函数,可以接收一个来自用户输入的数字字符串。我们需要对其预处理一下,去除多余空格,将其转换为数字并加一,最后返回该值对应的字母。代码大概长这样… [代码]const nextCharForNumStr = (str) => String.fromCharCode(parseInt(str.trim()) + 1) nextCharForNumStr(' 64 ') // "A" [代码] 因缺思厅,这代码嵌套的也太紧凑了,看多了“老阔疼”,赶紧重构一把… [图片] [代码]const nextCharForNumStr = (str) => { const trimmed = str.trim() const number = parseInt(trimmed) const nextNumber = number + 1 return String.fromCharCode(nextNumber) } nextCharForNumStr(' 64 ') // 'A' [代码] 很显然,经过之前内容的熏(xi)陶(nao),一眼就可以看出这个修订版代码很不 Pointfree… 为了这些只用一次的中间变量还要去想或者去查翻译,也是容易“老阔疼”,再改再改~ [图片] [代码]const nextCharForNumStr = (str) => [str] .map(s => s.trim()) .map(s => parseInt(s)) .map(i => i + 1) .map(i => String.fromCharCode(i)) nextCharForNumStr(' 64 ') // ['A'] [代码] 这次借助数组的 map 方法,我们将必须的4个步骤拆分成了4个小函数。 这样一来再也不用去想中间变量的名称到底叫什么,而且每一步做的事情十分的清晰,一眼就可以看出这段代码在干嘛。 我们将原本的字符串变量 str 放在数组中变成了 [str],这里就像放在一个容器里一样。 代码是不是感觉好 door~~ 了? [图片] 不过在这里我们可以更进一步,让我们来创建一个新的类型 Box。我们将同样定义 map 方法,让其实现同样的功能。 [代码]const Box = (x) => ({ map: f => Box(f(x)), // 返回容器为了链式调用 fold: f => f(x), // 将元素从容器中取出 inspect: () => `Box(${x})`, // 看容器里有啥 }) const nextCharForNumStr = (str) => Box(str) .map(s => s.trim()) .map(i => parseInt(i)) .map(i => i + 1) .map(i => String.fromCharCode(i)) .fold(c => c.toLowerCase()) // 可以轻易地继续调用新的函数 nextCharForNumStr(' 64 ') // a [代码] 此外创建一个容器,除了像函数一样直接传递参数以外,还可以使用静态方法 [代码]of[代码]。 函数式编程一般约定,函子有一个 of 方法,用来生成新的容器。 [代码]Box(1) === Box.of(1) [代码] 其实这个 [代码]Box[代码] 就是一个函子(functor),因为它实现了 [代码]map[代码] 函数。当然你也可以叫它 [代码]Mappable[代码] 或者其他名称。 不过为了保持与范畴学定义的名称一致,我们就站在巨人的肩膀上不要再发明新名词啦~(后面小节的各种奇怪名词也是来源于数学名词)。 functor 是实现了 map 函数并遵守一些特定规则的容器类型。 那么这些特定的规则具体是什么咧? ** 1. 规则一:** [代码]fx.map(f).map(g) === fx.map(x => f(g)(x)) [代码] 这其实就是函数组合… ** 2. 规则二:** [代码]const id = x => x fx.map(id) === id(fx) [代码] [图片] 4.2.Either / Maybe [图片] 假设现在有个需求:获取对应颜色的十六进制的 RGB 值,并返回去掉[代码]#[代码]后的大写值。 [代码]const findColor = (name) => ({ red: '#ff4444', blue: '#3b5998', yellow: '#fff68f', })[name] const redColor = findColor('red') .slice(1) .toUpperCase() // FF4444 const greenColor = findColor('green') .slice(1) .toUpperCase() // Uncaught TypeError: // Cannot read property 'slice' of undefined [代码] 以上代码在输入已有颜色的 [代码]key[代码] 值时运行良好,不过一旦传入其他颜色就会报错。咋办咧? 暂且不提条件判断和各种奇技淫巧的错误处理。咱们来先看看函数式的解决方案~ 函数式将错误处理抽象成一个 [代码]Either[代码] 容器,而这个容器由两个子容器 [代码]Right[代码] 和 [代码]Left[代码] 组成。 [代码]// Either 由 Right 和 Left 组成 const Left = (x) => ({ map: f => Left(x), // 忽略传入的 f 函数 fold: (f, g) => f(x), // 使用左边的函数 inspect: () => `Left(${x})`, // 看容器里有啥 }) const Right = (x) => ({ map: f => Right(f(x)), // 返回容器为了链式调用 fold: (f, g) => g(x), // 使用右边的函数 inspect: () => `Right(${x})`, // 看容器里有啥 }) // 来测试看看~ const right = Right(4) .map(x => x * 7 + 1) .map(x => x / 2) right.inspect() // Right(14.5) right.fold(e => 'error', x => x) // 14.5 const left = Left(4) .map(x => x * 7 + 1) .map(x => x / 2) left.inspect() // Left(4) left.fold(e => 'error', x => x) // error [代码] 可以看出 [代码]Right[代码] 和 [代码]Left[代码] 相似于 [代码]Box[代码]: 最大的不同就是 [代码]fold[代码] 函数,这里需要传两个回调函数,左边的给 [代码]Left[代码] 使用,右边的给 [代码]Right[代码] 使用。 其次就是 [代码]Left[代码] 的 [代码]map[代码] 函数忽略了传入的函数(因为出错了嘛,当然不能继续执行啦)。 现在让我们回到之前的问题来~ [代码]const fromNullable = (x) => x == null ? Left(null) : Right(x) const findColor = (name) => fromNullable(({ red: '#ff4444', blue: '#3b5998', yellow: '#fff68f', })[name]) findColor('green') .map(c => c.slice(1)) .fold( e => 'no color', c => c.toUpperCase() ) // no color [代码] 从以上代码不知道各位读者老爷们有没有看出使用 [代码]Either[代码] 的好处,那就是可以放心地对于这种类型的数据进行任何操作,而不是在每个函数里面小心翼翼地进行参数检查。 4.3.[代码]Chain[代码] / [代码]FlatMap[代码] / [代码]bind[代码] / [代码]>>=[代码] 假设现在有个 json 文件里面保存了端口,我们要读取这个文件获取端口,要是出错了返回默认值 3000。 [代码]// config.json { "port": 8888 } // chain.js const fs = require('fs') const getPort = () => { try { const str = fs.readFileSync('config.json') const { port } = JSON.parse(str) return port } catch(e) { return 3000 } } const result = getPort() [代码] so easy~,下面让我们来用 Either 来重构下看看效果。 [代码]const fs = require('fs') const Left = (x) => ({ ... }) const Right = (x) => ({ ... }) const tryCatch = (f) => { try { return Right(f()) } catch (e) { return Left(e) } } const getPort = () => tryCatch( () => fs.readFileSync('config.json') ) .map(c => JSON.parse(c)) .fold(e => 3000, c => c.port) [代码] 啊,常规操作,看起来不错哟~ 不错你个蛇头…! 以上代码有个 [代码]bug[代码],当 [代码]json[代码] 文件写的有问题时,在 [代码]JSON.parse[代码] 时会出错,所以这步也要用 [代码]tryCatch[代码] 包起来。 但是,问题来了… 返回值这时候可能是 [代码]Right(Right(''))[代码] 或者 [代码]Right(Left(e))[代码](想想为什么不是 [代码]Left(Right(''))[代码] 或者 [代码]Left(Left(e)))[代码]。 也就是说我们现在得到的是两层容器,就像俄罗斯套娃一样… 要取出容器中的容器中的值,我们就需要 [代码]fold[代码] 两次…!(若是再多几层…) <img height=“400” alt=“dog” src=“https://buptsteve.github.io/blog/imgs//fp-in-js/dog.jpg”> 因缺思厅,所以聪明机智的函数式又想出一个新方法 chain~,其实很简单,就是我知道这里要返回容器了,那就不要再用容器包了呗。 [代码]... const Left = (x) => ({ ... chain: f => Left(x) // 和 map 一样,直接返回 Left }) const Right = (x) => ({ ... chain: f => f(x), // 直接返回,不使用容器再包一层了 }) const tryCatch = (f) => { ... } const getPort = () => tryCatch( () => fs.readFileSync('config.json') ) .chain(c => tryCatch(() => JSON.parse(c))) // 使用 chain 和 tryCatch .fold( e => 3000, c => c.port ) [代码] 其实这里的 [代码]Left[代码] 和 [代码]Right[代码] 就是单子(Monad),因为它实现了 [代码]chain[代码] 函数。 monad 是实现了 chain 函数并遵守一些特定规则的容器类型。 在继续介绍这些特定规则前,我们先定义一个 [代码]join[代码] 函数: [代码]// 这里的 m 指的是一种 Monad 实例 const join = m => m.chain(x => x) [代码] 规则一: [代码]join(m.map(join)) === join(join(m)) [代码] 规则二: [代码]// 这里的 M 指的是一种 Monad 类型 join(M.of(m)) === join(m.map(M.of)) [代码] 这条规则说明了 [代码]map[代码] 可被 [代码]chain[代码] 和 [代码]of[代码] 所定义。 [代码]m.map(f) === m.chain(x => M.of(f(x))) [代码] 也就是说 [代码]Monad[代码] 一定是 [代码]Functor[代码] [代码]Monad[代码] 十分强大,之后我们将利用它处理各种副作用。但别对其感到困惑,[代码]chain[代码] 的主要作用不过将两种不同的类型连接([代码]join[代码])在一起罢了。 [图片] 4.4.半群(Semigroup) 定义一:对于非空集合 S,若在 S 上定义了二元运算 ○,使得对于任意的 a, b ∈ S,有 a ○ b ∈ S,则称 {S, ○} 为广群。 定义二:若 {S, ○} 为广群,且运算 ○ 还满足结合律,即:任意 a, b, c ∈ S,有 (a ○ b) ○ c = a ○ (b ○ c),则称 {S, ○} 为半群。 举例来说,JavaScript 中有 concat 方法的对象都是半群。 [代码]// 字符串和 concat 是半群 '1'.concat('2').concat('3') === '1'.concat('2'.concat('3')) // 数组和 concat 是半群 [1].concat([2]).concat([3]) === [1].concat([2].concat([3])) [代码] 虽然理论上对于 [代码]<Number, +>[代码] 来说它符合半群的定义: 数字相加返回的仍然是数字(广群) 加法满足结合律(半群) 但是数字并没有 concat 方法 没事儿,让我们来实现这个由 [代码]<Number, +>[代码] 组成的半群 Sum。 [代码]const Sum = (x) => ({ x, concat: ({ x: y }) => Sum(x + y), // 采用解构获取值 inspect: () => `Sum(${x})`, }) Sum(1) .concat(Sum(2)) .inspect() // Sum(3) [代码] 除此之外,[代码]<Boolean, &&>[代码] 也满足半群的定义~ [代码]const All = (x) => ({ x, concat: ({ x: y }) => All(x && y), // 采用解构获取值 inspect: () => `All(${x})`, }) All(true) .concat(All(false)) .inspect() // All(false) [代码] 最后,让我们对于字符串创建一个新的半群 First,顾名思义,它会忽略除了第一个参数以外的内容。 [代码]const First = (x) => ({ x, concat: () => First(x), // 忽略后续的值 inspect: () => `First(${x})`, }) First('blah') .concat(First('yoyoyo')) .inspect() // First('blah') [代码] 咿呀哟?是不是感觉这个半群和其他半群好像有点儿不太一样,不过具体是啥又说不上来…? 这个问题留给下个小节。在此先说下这玩意儿有啥用。 [代码]const data1 = { name: 'steve', isPaid: true, points: 10, friends: ['jame'], } const data2 = { name: 'steve', isPaid: false, points: 2, friends: ['young'], } [代码] 假设有两个数据,需要将其合并,那么利用半群,我们可以对 name 应用 First,对于 isPaid 应用 All,对于 points 应用 Sum,最后的 friends 已经是半群了… [代码]const Sum = (x) => ({ ... }) const All = (x) => ({ ... }) const First = (x) => ({ ... }) const data1 = { name: First('steve'), isPaid: All(true), points: Sum(10), friends: ['jame'], } const data2 = { name: First('steve'), isPaid: All(false), points: Sum(2), friends: ['young'], } const concatObj = (obj1, obj2) => Object.entries(obj1) .map(([ key, val ]) => ({ // concat 两个对象的值 [key]: val.concat(obj2[key]), })) .reduce((acc, cur) => ({ ...acc, ...cur })) concatObj(data1, data2) /* { name: First('steve'), isPaid: All(false), points: Sum(12), friends: ['jame', 'young'], } */ [代码] 4.5.幺半群(Monoid) 幺半群是一个存在单位元(幺元)的半群。 半群我们都懂,不过啥是单位元? 单位元:对于半群 <S, ○>,存在 e ∈ S,使得任意 a ∈ S 有 a ○ e = e ○ a 举例来说,对于数字加法这个半群来说,0就是它的单位元,所以 [代码]<Number, +, 0>[代码] 就构成一个幺半群。同理: 对于 [代码]<Number, *>[代码] 来说单位元就是 1 对于 [代码]<Boolean, &&>[代码] 来说单位元就是 true 对于 [代码]<Boolean, ||>[代码] 来说单位元就是 false 对于 [代码]<Number, Min>[代码] 来说单位元就是 Infinity 对于 [代码]<Number, Max>[代码] 来说单位元就是 -Infinity 那么 [代码]<String, First>[代码] 是幺半群么? 显然我们并不能找到这样一个单位元 e 满足 [代码]First(e).concat(First('steve')) === First('steve').concat(First(e))[代码] 这就是上一节留的小悬念,为何会感觉 First 与 Sum 和 All 不太一样的原因。 格叽格叽,这两者有啥具体的差别么? 其实看到幺半群的第一反应应该是默认值或初始值,例如 reduce 函数的第二个参数就是传入一个初始值或者说是默认值。 [代码]// sum const Sum = (x) => ({ ... }) Sum.empty = () => Sum(0) // 单位元 const sum = xs => xs.reduce((acc, cur) => acc + cur, 0) sum([1, 2, 3]) // 6 sum([]) // 0,而不是报错! // all const All = (x) => ({ ... }) All.empty = () => All(true) // 单位元 const all = xs => xs.reduce((acc, cur) => acc && cur, true) all([true, false, true]) // false all([]) // true,而不是报错! // first const First = (x) => ({ ... }) const first = xs => xs.reduce(acc, cur) => acc) first(['steve', 'jame', 'young']) // steve first([]) // boom!!! [代码] 从以上代码可以看出幺半群比半群要安全得多, 4.6.foldMap 1.套路 在上一节中幺半群的使用代码中,如果传入的都是幺半群实例而不是原始类型的话,你会发现其实都是一个套路… [代码]const Monoid = (x) => ({ ... }) const monoid = xs => xs.reduce( (acc, cur) => acc.concat(cur), // 使用 concat 结合 Monoid.empty() // 传入幺元 ) monoid([Monoid(a), Monoid(b), Monoid(c)]) // 传入幺半群实例 [代码] 所以对于思维高度抽象的函数式来说,这样的代码肯定是需要继续重构精简的~ 2.List、Map 在讲解如何重构之前,先介绍两个炒鸡常用的不可变数据结构:[代码]List[代码]、[代码]Map[代码]。 顾名思义,正好对应原生的 [代码]Array[代码] 和 [代码]Object[代码]。 3.利用 List、Map 重构 因为 [代码]immutable[代码] 库中的 [代码]List[代码] 和 [代码]Map[代码] 并没有 [代码]empty[代码] 属性和 [代码]fold[代码] 方法,所以我们首先扩展 List 和 Map~ [代码]import { List, Map } from 'immutable' const derived = { fold (empty) { return this.reduce((acc, cur) => acc.concat(cur), empty) }, } List.prototype.empty = List() List.prototype.fold = derived.fold Map.prototype.empty = Map({}) Map.prototype.fold = derived.fold // from https://github.com/DrBoolean/immutable-ext [代码] 这样一来上一节的代码就可以精简成这样: [代码]List.of(1, 2, 3) .map(Sum) .fold(Sum.empty()) // Sum(6) List().fold(Sum.empty()) // Sum(0) Map({ steve: 1, young: 3 }) .map(Sum) .fold(Sum.empty()) // Sum(4) Map().fold(Sum.empty()) // Sum(0) [代码] 4.利用 foldMap 重构 注意到 [代码]map[代码] 和 [代码]fold[代码] 这两步操作,从逻辑上来说是一个操作,所以我们可以新增 [代码]foldMap[代码] 方法来结合两者。 [代码]import { List, Map } from 'immutable' const derived = { fold (empty) { return this.foldMap(x => x, empty) }, foldMap (f, empty) { return empty != null // 幺半群中将 f 的调用放在 reduce 中,提高效率 ? this.reduce( (acc, cur, idx) => acc.concat(f(cur, idx)), empty ) : this // 在 map 中调用 f 是因为考虑到空的情况 .map(f) .reduce((acc, cur) => acc.concat(cur)) }, } List.prototype.empty = List() List.prototype.fold = derived.fold List.prototype.foldMap = derived.foldMap Map.prototype.empty = Map({}) Map.prototype.fold = derived.fold Map.prototype.foldMap = derived.foldMap // from https://github.com/DrBoolean/immutable-ext [代码] 所以最终版长这样: [代码]List.of(1, 2, 3) .foldMap(Sum, Sum.empty()) // Sum(6) List() .foldMap(Sum, Sum.empty()) // Sum(0) Map({ a: 1, b: 3 }) .foldMap(Sum, Sum.empty()) // Sum(4) Map() .foldMap(Sum, Sum.empty()) // Sum(0) [代码] 4.7.LazyBox 下面我们要来实现一个新容器 [代码]LazyBox[代码]。 顾名思义,这个容器很懒… 虽然你可以不停地用 [代码]map[代码] 给它分配任务,但是只要你不调用 [代码]fold[代码] 方法催它执行(就像 [代码]deadline[代码] 一样),它就死活不执行… [代码]const LazyBox = (g) => ({ map: f => LazyBox(() => f(g())), fold: f => f(g()), }) const result = LazyBox(() => ' 64 ') .map(s => s.trim()) .map(i => parseInt(i)) .map(i => i + 1) .map(i => String.fromCharCode(i)) // 没有 fold 死活不执行 result.fold(c => c.toLowerCase()) // a [代码] 4.8.Task 1.基本介绍 有了上一节中 [代码]LazyBox[代码] 的基础之后,接下来我们来创建一个新的类型 [代码]Task。[代码] 首先 [代码]Task[代码] 的构造函数可以接收一个函数以便延迟计算,当然也可以用 [代码]of[代码] 方法来创建实例,很自然的也有 [代码]map[代码]、[代码]chain[代码]、[代码]concat[代码]、[代码]empty[代码] 等方法。 与众不同的是它有个 [代码]fork[代码] 方法(类似于 [代码]LazyBox[代码] 中的 [代码]fold[代码] 方法,在 [代码]fork[代码] 执行前其他函数并不会执行),以及一个 [代码]rejected[代码] 方法,类似于 [代码]Left[代码],忽略后续的操作。 [代码]import Task from 'data.task' const showErr = e => console.log(`err: ${e}`) const showSuc = x => console.log(`suc: ${x}`) Task .of(1) .fork(showErr, showSuc) // suc: 1 Task .of(1) .map(x => x + 1) .fork(showErr, showSuc) // suc: 2 // 类似 Left Task .rejected(1) .map(x => x + 1) .fork(showErr, showSuc) // err: 1 Task .of(1) .chain(x => new Task.of(x + 1)) .fork(showErr, showSuc) // suc: 2 [代码] 2.使用示例 接下来让我们做一个发射飞弹的程序~ [代码]const lauchMissiles = () => ( // 和 promise 很像,不过 promise 会立即执行 // 而且参数的位置也相反 new Task((rej, res) => { console.log('lauchMissiles') res('missile') }) ) // 继续对之前的任务添加后续操作(duang~给飞弹加特技!) const app = lauchMissiles() .map(x => x + '!') // 这时才执行(发射飞弹) app.fork(showErr, showSuc) [代码] 3.原理意义 上面的代码乍一看好像没啥用,只不过是把待执行的代码用函数包起来了嘛,这还能吹上天? 还记得前面章节说到的副作用么?虽然说使用纯函数是没有副作用的,但是日常项目中有各种必须处理的副作用。 所以我们将有副作用的代码给包起来之后,这些新函数就都变成了纯函数,这样我们的整个应用的代码都是纯的~,并且在代码真正执行前([代码]fork[代码] 前)还可以不断地 [代码]compose[代码] 别的函数,为我们的应用不断添加各种功能,这样一来整个应用的代码流程都会十分的简洁漂亮。 [图片] 4.异步嵌套示例 以下代码做了 3 件事: 读取 config1.json 中的数据 将内容中的 8 替换成 6 将新内容写到 config2.json 中 [代码]import fs from 'fs' const app = () => ( fs.readFile('config1.json', 'utf-8', (err, contents) => { if (err) throw err const newContents = content.replace(/8/g, '6') fs.writeFile('config2.json', newContents, (err, _) => { if (err) throw err console.log('success!') }) }) ) [代码] 让我们用 Task 来改写一下~ [代码]import fs from 'fs' import Task from 'data.task' const cfg1 = 'config1.json' const cfg2 = 'config2.json' const readFile = (file, enc) => ( new Task((rej, res) => fs.readFile(file, enc, (err, str) => err ? rej(err) : res(str) ) ) ) const writeFile = (file, str) => ( new Task((rej, res) => fs.writeFile(file, str, (err, suc) => err ? rej(err) : res(suc) ) ) ) const app = readFile(cfg1, 'utf-8') .map(str => str.replace(/8/g, '6')) .chain(str => writeFile(cfg2, str)) app.fork( e => console.log(`err: ${e}`), x => console.log(`suc: ${x}`) ) [代码] 代码一目了然,按照线性的先后顺序完成了任务,并且在其中还可以随意地插入或修改需求~ 4.9.Applicative Functor 1.问题引入 [代码]Applicative Functor[代码] 提供了让不同的函子(functor)互相应用的能力。 为啥我们需要函子的互相应用?什么是互相应用? 先来看个简单例子: [代码]const add = x => y => x + y add(Box.of(2))(Box.of(3)) // NaN Box(2).map(add).inspect() // Box(y => 2 + y) [代码] 现在我们有了一个容器,它的内部值为局部调用(partially applied)后的函数。接着我们想让它应用到 [代码]Box(3)[代码] 上,最后得到 [代码]Box(5)[代码] 的预期结果。 说到从容器中取值,那肯定第一个想到 [代码]chain[代码] 方法,让我们来试一下: [代码]Box(2) .chain(x => Box(3).map(add(x))) .inspect() // Box(5) [代码] 成功实现~,BUT,这种实现方法有个问题,那就是单子(Monad)的执行顺序问题。 我们这样实现的话,就必须等 [代码]Box(2)[代码] 执行完毕后,才能对 [代码]Box(3)[代码] 进行求值。假如这是两个异步任务,那么完全无法并行执行。 别慌,吃口药~ 2.基本介绍 下面介绍下主角:[代码]ap[代码]~: [代码]const Box = (x) => ({ // 这里 box 是另一个 Box 的实例,x 是函数 ap: box => box.map(x), ... }) Box(add) // Box(y => 2 + y) ,咦?在哪儿见过? .ap(Box(2)) .ap(Box(3)) // Box(5) [代码] 运算规则 [代码]F(x).map(f) === F(f).ap(F(x)) // 这就是为什么 Box(2).map(add) === Box(add).ap(Box(2)) [代码] 3.Lift 家族 由于日常编写代码的时候直接用 ap 的话模板代码太多,所以一般通过使用 Lift 家族系列函数来简化。 [代码]// F 该从哪儿来? const fakeLiftA2 = f => fx => fy => F(f).ap(fx).ap(fy) // 应用运算规则转换一下~ const liftA2 = f => fx => fy => fx.map(f).ap(fy) liftA2(add, Box(2), Box(4)) // Box(6) // 同理 const liftA3 = f => fx => fy => fz => fx.map(f).ap(fy).ap(fz) const liftA4 = ... ... const liftAN = ... [代码] 4.Lift 应用 例1 [代码]// 假装是个 jQuery 接口~ const $ = selector => Either.of({ selector, height: 10 }) const getScreenSize = screen => head => foot => screen - (head.height + foot.height) liftA2(getScreenSize(800))($('header'))($('footer')) // Right(780) [代码] 例2 [代码]// List 的笛卡尔乘积 List.of(x => y => z => [x, y, z].join('-')) .ap(List.of('tshirt', 'sweater')) .ap(List.of('white', 'black')) .ap(List.of('small', 'medium', 'large')) [代码] 例3 [代码]const Db = ({ find: (id, cb) => new Task((rej, res) => setTimeout(() => res({ id, title: `${id}`}), 100) ) }) const reportHeader = (p1, p2) => `Report: ${p1.title} compared to ${p2.title}` Task.of(p1 => p2 => reportHeader(p1, p2)) .ap(Db.find(20)) .ap(Db.find(8)) .fork(console.error, console.log) // Report: 20 compared to 8 liftA2 (p1 => p2 => reportHeader(p1, p2)) (Db.find(20)) (Db.find(8)) .fork(console.error, console.log) // Report: 20 compared to 8 [代码] 4.10.Traversable 1.问题引入 [代码]import fs from 'fs' // 详见 4.8. const readFile = (file, enc) => ( new Task((rej, res) => ...) ) const files = ['a.js', 'b.js'] // [Task, Task],我们得到了一个 Task 的数组 files.map(file => readFile(file, 'utf-8')) [代码] 然而我们想得到的是一个包含数组的 [代码]Task([file1, file2])[代码],这样就可以调用它的 [代码]fork[代码] 方法,查看执行结果。 为了解决这个问题,函数式编程一般用一个叫做 [代码]traverse[代码] 的方法来实现。 [代码]files .traverse(Task.of, file => readFile(file, 'utf-8')) .fork(console.error, console.log) [代码] [代码]traverse[代码] 方法第一个参数是创建函子的函数,第二个参数是要应用在函子上的函数。 2.实现 其实以上代码有 [代码]bug[代码]…,因为数组 Array 是没有 [代码]traverse[代码] 方法的。没事儿,让我们来实现一下~ [代码]Array.prototype.empty = [] // traversable Array.prototype.traverse = function (point, fn) { return this.reduce( (acc, cur) => acc .map(z => y => z.concat(y)) .ap(fn(cur)), point(this.empty) ) } [代码] 看着有点儿晕? 不急,首先看代码主体是一个 [代码]reduce[代码],这个很熟了,就是从左到右遍历元素,其中的第二个参数传递的就是幺半群(monoid)的单位元(empty)。 再看第一个参数,主要就是通过 [代码]applicative functor[代码] 调用 [代码]ap[代码] 方法,再将其执行结果使用 [代码]concat[代码] 方法合并到数组中。 所以最后返回的就是 [代码]Task([foo, bar])[代码],因此我们可以调用 [代码]fork[代码] 方法执行它。 4.11.自然变换(Natural Transformations) 1.基本概念 自然变换就是一个函数,接受一个函子(functor),返回另一个函子。看看代码熟悉下~ [代码]const boxToEither = b => b.fold(Right) [代码] 这个 [代码]boxToEither[代码] 函数就是一个自然变换(nt),它将函子 [代码]Box[代码] 转换成了另一个函子 [代码]Either[代码]。 那么我们用 [代码]Left[代码] 行不行呢? 答案是不行! 因为自然变换不仅是将一个函子转换成另一个函子,它还满足以下规则: [代码]nt(x).map(f) == nt(x.map(f)) [代码] [图片] 举例来说就是: [代码]const res1 = boxToEither(Box(100)) .map(x => x * 2) const res2 = boxToEither( Box(100).map(x => x * 2) ) res1 === res2 // Right(200) [代码] 即先对函子 [代码]a[代码] 做改变再将其转换为函子 [代码]b[代码],是等价于先将函子 [代码]a[代码] 转换为函子 [代码]b[代码] 再做改变。 显然,[代码]Left[代码] 并不满足这个规则。所以任何满足这个规则的函数都是自然变换。 2.应用场景 1.例1:得到一个数组小于等于 100 的最后一个数的两倍的值 [代码]const arr = [2, 400, 5, 1000] const first = xs => fromNullable(xs[0]) const double = x => x * 2 const getLargeNums = xs => xs.filter(x => x > 100) first( getLargeNums(arr).map(double) ) [代码] 根据自然变换,它显然和 [代码]first(getLargeNums(arr)).map(double)[代码] 是等价的。但是后者显然性能好得多。 再来看一个更复杂一点儿的例子: 2.例2:找到 id 为 3 的用户的最好的朋友的 id [代码]// 假 api const fakeApi = (id) => ({ id, name: 'user1', bestFriendId: id + 1, }) // 假 Db const Db = { find: (id) => new Task( (rej, res) => ( res(id > 2 ? Right(fakeApi(id)) : Left('not found') ) ) ) } [代码] [代码]// Task(Either(user)) const zero = Db.find(3) // 第一版 // Task(Either(Task(Either(user)))) ??? const one = zero .map(either => either .map(user => Db .find(user.bestFriendId) ) ) .fork( console.error, either => either // Either(Task(Either(user))) .map(t => t.fork( // Task(Either(user)) console.error, either => either .map(console.log), // Either(user) )) ) [代码] [图片] 这是什么鬼??? 肯定不能这么干… [代码]// Task(Either(user)) const zero = Db.find(3) // 第二版 const two = zero .chain(either => either .fold(Task.rejected, Task.of) // Task(user) .chain(user => Db .find(user.bestFriendId) // Task(Either(user)) ) .chain(either => either .fold(Task.rejected, Task.of) // Task(user) ) ) .fork( console.error, console.log, ) [代码] 第二版的问题是多余的嵌套代码。 [代码]// Task(Either(user)) const zero = Db.find(3) // 第三版 const three = zero .chain(either => either .fold(Task.rejected, Task.of) // Task(user) ) .chain(user => Db .find(user.bestFriendId) // Task(Either(user)) ) .chain(either => either .fold(Task.rejected, Task.of) // Task(user) ) .fork( console.error, console.log, ) [代码] 第三版的问题是多余的重复逻辑。 [代码]// Task(Either(user)) const zero = Db.find(3) // 这其实就是自然变换 // 将 Either 变换成 Task const eitherToTask = (e) => ( e.fold(Task.rejected, Task.of) ) // 第四版 const four = zero .chain(eitherToTask) // Task(user) .chain(user => Db .find(user.bestFriendId) // Task(Either(user)) ) .chain(eitherToTask) // Task(user) .fork( console.error, console.log, ) // 出错版 const error = Db.find(2) // Task(Either(user)) // Task.rejected('not found') .chain(eitherToTask) // 这里永远不会被调用,被跳过了 .chain(() => console.log('hey man')) ... .fork( console.error, // not found console.log, ) [代码] 4.12.同构(Isomorphism) 同构是在数学对象之间定义的一类映射,它能揭示出在这些对象的属性或者操作之间存在的关系。 简单来说就是两种不同类型的对象经过变形,保持结构并且不丢失数据。 具体怎么做到的呢? 其实同构就是一对儿函数:[代码]to[代码] 和 [代码]from[代码],遵守以下规则: [代码]to(from(x)) === x from(to(y)) === y [代码] 这其实说明了这两个类型都能够无损地保存同样的信息。 1. 例如 [代码]String[代码] 和 [代码][Char][代码] 就是同构的。 [代码]// String ~ [Char] const Iso = (to, from) => ({ to, from }) const chars = Iso( s => s.split(''), c => c.join('') ) const str = 'hello world' chars.from(chars.to(str)) === str [代码] 这能有啥用呢? [代码]const truncate = (str) => ( chars.from( // 我们先用 to 方法将其转成数组 // 这样就能使用数组的各类方法 chars.to(str).slice(0, 3) ).concat('...') ) truncate(str) // hel... [代码] 2. 再来看看最多有一个参数的数组 [代码][a][代码] 和 [代码]Either[代码] 的同构关系 [代码]// [a] ~ Either null a const singleton = Iso( e => e.fold(() => [], x => [x]), ([ x ]) => x ? Right(x) : Left() ) const filterEither = (e, pred) => singleton .from( singleton .to(e) .filter(pred) ) const getUCH = (str) => filterEither( Right(str), x => x.match(/h/ig) ).map(x => x.toUpperCase()) getUCH('hello') // Right(HELLO) getUCH('ello') // Left(undefined) [代码] 参考资料 JS函数式编程指南 Pointfree 编程风格指南 Hey Underscore, You’re Doing It Wrong! Functional Concepts with JavaScript: Part I Professor Frisby Introduces Composable Functional JavaScript 函数式编程入门教程 What are Functional Programming, Monad, Monoid, Applicative, Functor ?? 以上 to be continued… 搬运自本人 blog https://github.com/BuptStEve/blog
2019-03-15