- 让小程序页面和自定义组件支持 computed 和 watch 数据监听器
习惯于 VUE 或其他一些框架的同学们可能会经常使用它们的 [代码]computed[代码] 和 [代码]watch[代码] 。 小程序框架本身并没有提供这个功能,但我们基于现有的特性,做了一个 npm 模块来提供 [代码]computed[代码] 和 [代码]watch[代码] 功能。 先来个 GitHub 链接:https://github.com/wechat-miniprogram/computed 如何使用? 安装 npm 模块 [代码]npm install --save miniprogram-computed [代码] 示例代码 [代码]const computedBehavior = require('miniprogram-computed') Component({ behaviors: [computedBehavior], data: { a: 1, b: 1, }, computed: { sum(data) { return data.a + data.b }, }, }) [代码] [代码]const computedBehavior = require('miniprogram-computed') Component({ behaviors: [computedBehavior], data: { a: 1, b: 1, sum: 2, }, watch: { 'a, b': function(a, b) { this.setData({ sum: a + b }) }, }, }) [代码] 怎么在页面中使用? 其实上面的示例不仅在自定义组件中可以使用,在页面中也是可以的——因为小程序的页面也可用 [代码]Component[代码] 构造器来创建! 如果你已经有一个这样的页面: [代码]Page({ data: { a: 1, b: 1, }, onLoad: function() { /* ... */ }, myMethod1: function() { /* ... */ }, myMethod2: function() { /* ... */ }, }) [代码] 可以先把它改成: [代码]Component({ data: { a: 1, b: 1, }, methods: { onLoad: function() { /* ... */ }, myMethod1: function() { /* ... */ }, myMethod2: function() { /* ... */ }, }, }) [代码] 然后就可以用了: [代码]const computedBehavior = require('miniprogram-computed') Component({ behaviors: [computedBehavior], data: { a: 1, b: 1, }, computed: { sum(data) { return data.a + data.b }, }, methods: { onLoad: function() { /* ... */ }, myMethod1: function() { /* ... */ }, myMethod2: function() { /* ... */ }, }, }) [代码] 应该使用 [代码]computed[代码] 还是 [代码]watch[代码] ? 看起来 [代码]computed[代码] 和 [代码]watch[代码] 具有类似的功能,应该使用哪个呢? 一个简单的原则: [代码]computed[代码] 只有 [代码]data[代码] 可以访问,不能访问组件的 [代码]methods[代码] (但可以访问组件外的通用函数)。如果满足这个需要,使用 [代码]computed[代码] ,否则使用 [代码]watch[代码] 。 想知道原理? [代码]computed[代码] 和 [代码]watch[代码] 主要基于两个自定义组件特性: 数据监听器 和 自定义组件扩展 。其中,数据监听器 [代码]observers[代码] 可以用来监听数据被 [代码]setData[代码] 操作。 对于 [代码]computed[代码] ,每次执行 [代码]computed[代码] 函数时,记录下有哪些 data 中的字段被依赖。如果下一次 [代码]setData[代码] 后这些字段被改变了,就重新执行这个 [代码]computed[代码] 函数。 对于 [代码]watch[代码] ,它和 [代码]observers[代码] 的区别不大。区别在于,如果一个 data 中的字段被设置但未被改变,普通的 [代码]observers[代码] 会触发,但 [代码]watch[代码] 不会。 如果遇到问题或者有好的建议,可以在 GitHub 提 issue 。
2019-07-24 - JavaScript常用设计模式示例与应用
JavaScript常用设计模式实例与应用 前言 1. 什么是设计模式 小时候打游戏,我们总是追求快速完美通关;上下班交通,我们总是会选择最方便便捷乘车路线。我们总是追求一件事情的最优美便捷的解决方案,也就是其所谓的最佳实践。 一个设计模式就是一个可重用的方案,可应用于在软件设计中常见的问题,在本次分享主题中,就是编写JavaScript的web应用程序中常见的问题,设计模式的另一种解释就是一个我们如何解决问题的模板。那些在许多不同但类似的情况下使用的模板。 2. 为什么要学习设计模式 JavaScript是一门以原型为基础,面向对象的,动态数据类型语言。在把函数视为第一公民,支持函数式编程的同时也不排斥面向对象的开发方式,甚至在ES6+的标准中还引入了面向对象的一些原生支持。这使得JavaScript成为一门功能强大的语言同时也导致了编程风格的碎片化,同一个功能实现的多样性。对于一些传统的、强面向对象的设计模式会有各种类型的实现,有时候会让人感觉牵强。但是这些并不妨碍我们使用JavaScript来表达设计模式的理念、所要解决的问题以及它的核心思想,这才是我们所要关注的核心。 设计模式可以让我们站在巨人的肩膀上,获得前人的经验,保证我们以优雅的方式组织我们的代码并满足我们解决问题所需要的条件。 内容 一、设计原则 设计原则是指导思想,是我们在程序设计中尽可能要遵守的准则。设计模式就是这些设计原则的一些具体实现,所要达到的目标就是高内聚低耦合。在这里我简单介绍一些六大设计原则中的单一职责原则(SPR)、开放封闭原则(OCP)、最少知识原则(LKP)。 1. 单一职责原则 单一职责原则指的是一个类应该仅有一个引起它变化的原因,也就是说一个对象只做一件事情。这样做可以让我们对对象的维护变得简单,如果一个对象拥有多种职责,职责之间相互耦合,对一个职责的修改势必会影响到其他职责。也就是说,一个对象负责的职责越多,耦合越强,对模块的修改就越危险。 2. 开放封闭原则 开放封闭原则指的是一个模块应该在对扩展开放,而对修改封闭。当需要修改增加需求的时候,应该尽量通过扩展新代码的方式,而不是修改已有的代码。因为修改已有代码会给依赖原有代码的模块带来隐患,从而需要把依赖原有代码的模块重新测试一遍,加重测试成本。 3. 最少知识原则 最少知识原则指的是一个类应该对自己需要耦合或调用的类了解得尽可能少,调用者或依赖着仅需要知道他所需要的方法即可,其他的概不关心。因为类与类之间的关系越密切,耦合性越高,当一个类发生改变时,对另一个类的影响也越大。通常我们减少对象之间的联系的方法是引入一个第三者来帮助通信,阻隔对象之间的直接通信,从而减少耦合。 二、设计模式的分类 设计模式可以被分成几个不同的种类: 创建型设计模式 创建型设计模式关注的是对象创建的机制方法,一般会把对象的创建和使用分离,从而帮助创建类的实例对象。属于这一类的设计模式主要有:构造器模式、工厂模式、单例模式、建造者模式等。 结构型设计模式 结构型设计模式关注对象组成以及不同对象之间的关系。这类模式有助于在系统的某一部分发生变化时减少对整个系统结构的改变。主要包括:代理模式、享元模式、外观模式、适配器模式、装饰者模式等。 行为型设计模式 行为型设计模式关注对象之间的通信,描述对象之间如何相互协作。主要包括:发布订阅模式,策略模式,状态模式,迭代器模式,命令模式,职责链模式,中介者模式等。 三、设计模式示例 1. 单例模式 单例模式(Singleton Pattern)属于创建型设计模式,它限制一个类只能有一个实例化对象,并提供一个访问它的全局访问点。 单例模式可能是最简单的设计模式了,虽然简单,但在实际项目开发中是很常用的一种模式。 单例模式中有几个需要知道的概念: Singleton:特定的类,也就是我们需要访问的类,访问者要拿到的就是它的实例。 Instance: 单例,是特定类的唯一实例。 getInstance: 获取单例的方法。 代码示例 [代码]var GameManager = (function () { // 单例 var instance; function init() { // 私有变量和方法 var _saveData = { name: 'glenn', level: 1 }; function _privateMethod(){ console.log( "I am private function" ); } return { // 公有变量和方法 levelUp: function(){ _saveData.level ++; }, getCurLevel: function(){ return _saveData.level; }, getName: function(){ return _saveData.name; }, publicProperty: "this is a public prop", }; }; return { // 如果存在获取此单例实例,如果不存在创建一个单例实例 getInstance: function () { if ( !instance ) { instance = init(); } return instance; } }; })(); // 使用: var singleA = GameManager.getInstance(); singleA.levelUp(); var singleB = GameManager.getInstance(); console.log( singleA.getCurLevel() === singleB.getCurLevel() ); // true [代码] 在本例中,GameManager是一个单例类,我们首先使用立即调用函数IIFE把希望隐藏的单例示例instance隐藏起来,在init方法中定义该单例类的公有和私有方法变量,然后返回一个对象,把获取单例实例的方法getInstance暴露出去。在getInstance方法中,通过JavaScript的闭包特性把单例实例instance存进闭包中,在第一次获取实例时才初始化单例,并在之后的获取操作中返回的都是这个相同的实例。 可以看到,在使用单例的代码中,我们调用了两次getInstance获取的两个对象singleA和singleB指向的是同一个对象。 源码中的单例模式 以 ElementUI 为例,ElementUI中的全屏Loading蒙层使用服务的形式调用的使用方式示意: [代码]Vue.prototype.$loading = service; this.$loading({ fullscreen: true }); [代码] 我们可以看看这个loading在ElementUI2.9.2源码中是如何实现的。 下面是为了方便观看省略了部分代码后的源码 [代码]import Vue from 'vue'; import loadingVue from './loading.vue'; const LoadingConstructor = Vue.extend(loadingVue); //... //单例 let fullscreenLoading; LoadingConstructor.prototype.originalPosition = ''; LoadingConstructor.prototype.originalOverflow = ''; LoadingConstructor.prototype.close = function() { //... }; const addStyle = (options, parent, instance) => { //... }; const Loading = (options = {}) => { //... //判断示例是否已经初始化 if (options.fullscreen && fullscreenLoading) { return fullscreenLoading; } //一系列的初始化操作 let parent = options.body ? document.body : options.target; let instance = new LoadingConstructor({ el: document.createElement('div'), data: options }); addStyle(options, parent, instance); if (instance.originalPosition !== 'absolute' && instance.originalPosition !== 'fixed') { addClass(parent, 'el-loading-parent--relative'); } if (options.fullscreen && options.lock) { addClass(parent, 'el-loading-parent--hidden'); } parent.appendChild(instance.$el); Vue.nextTick(() => { instance.visible = true; }); //把初始化出来的实例缓存下来 if (options.fullscreen) { fullscreenLoading = instance; } return instance; }; export default Loading; [代码] 这里的单例是fullscreenLoading,缓存在闭包中。当用户调用时传入的options中fullscreen为true且之前已经创建并初始化过单例的情况下直接返回之前创建的单例,否则继续执行后面的初始化操作,并把创建的单例赋值给闭包中的fullscreenLoading后返回新创建的单例。 这是一个典型的单例模式应用,通过复用之前创建的全屏加载蒙层单例,不仅减少了重复实例化过程带来的额外开销,还保证了页面中不会出现重复的全屏加载蒙层。 单例模式的应用场景 当项目中需要一个公共的状态管理时,我们可以引入单例模式来确保访问的一致性。 当项目中存在一些同一时间只会出现一个且会重复出现的对象时,我们可以引入单例模式避免重复创建对象产生的多余开销,例如项目中的弹窗,消息框提醒等。 2. 外观模式 外观模式又叫门面模式,属于结构型模式,它将子系统的一系列复杂的接口集成起来组成一个更高级别的更舒适的高层接口,从而隐藏其真正的潜在复杂性,对外提供一个一致的外观。 外观模式让外界减少对子系统的直接交互,从而降低耦合,让外界可以轻松使用子系统,其本质是封装交互,简化调用。 代码示例 [代码]var module = (function() { var _sportsman = { speed: 5, height: 10, set : function(key, val) { this[key] = val; }, run : function() { console.log('运动呀正在以'+this.speed+'米每秒的速度向前跑着。'); }, jump: function(){ console.log( "运动员往上跳了"+this.height+'米'); } }; return { facade : function( args ) { args.speed != undefined && _sportsman.set('speed', args.speed); args.height != undefined && _sportsman.set('height', args.height); args.run && _sportsman.run(); args.jump && _sportsman.jump(); } }; }()); // Outputs: 运动呀正在以10米每秒的速度向前跑着。 // 运动员往上跳了5米 module.facade( {run: true, speed: 10, jump: true, height: 5} ); [代码] 这是表达外观模式一个简单的例子。在本例中,调用module的门面方法facede会触发运动员对象_sportsman中的一系列私有方法。但在这一次,用户不需要关心运动员对象内部方法的实现,就可以让运动员动起来。 源码中的外观模式 当我们使用Jquery的$(document).ready()来给浏览器加载完成添加事件回调时,Jquery会调用源码中的私有方法: [代码]// ... bindReady: function() { //... // Mozilla, Opera and webkit nightlies currently support this event if ( document.addEventListener ) { // Use the handy event callback document.addEventListener( "DOMContentLoaded", DOMContentLoaded, false ); // A fallback to window.onload, that will always work window.addEventListener( "load", jQuery.ready, false ); // If IE event model is used } else if ( document.attachEvent ) { // ensure firing before onload, // maybe late but safe also for iframes document.attachEvent( "onreadystatechange", DOMContentLoaded ); // A fallback to window.onload, that will always work window.attachEvent( "onload", jQuery.ready ); // If IE and not a frame // continually check to see if the document is ready var toplevel = false; try { toplevel = window.frameElement == null; } catch(e) {} if ( document.documentElement.doScroll && toplevel ) { doScrollCheck(); } } } // ... [代码] 由于IE9之前的IE版本浏览器以及Opera7.0之前的Opera浏览器不支持addEventListener方法,在需要适配这些浏览器的项目中,我们需要自己手动判断浏览器版版本来决定使用什么事件绑定方法以及事件。而如果使用了Jquery库中提供的这个外观方法,用户则不需要关心浏览器的兼容问题,使用一致的外观接口$(document).ready()就可以实现监听浏览器加载完成事件的功能,从而简化了使用。 除了抹平浏览器的兼容性问题之外,Jquery还有一些其他的外观模式的应用: 比如设置或获取dom结点的内容和属性时使用的text()、html()和val()方法时,Jquery判断调用方法是否有传参数来确定是设置还是获取操作。这里Jquery把设置和获取操作对外提供了同一个外观接口,使调用简化了不少。 再比如Jquery的ajax的API[代码]$.ajax(url[,settings])[代码],当我们需要设置以JSONP的形式发送请求时,只需要传入[代码]dataType: 'jsonp'[代码]设置,jquery会进行额外的操作帮我们启动JSONP流程,而不需要调用者添加额外的代码。 外观模式的适用场景 维护设计粗糙和难以理解的上古系统,或者非常复杂的一些系统时,可以为这些系统设置一个外观模块,给外界提供清晰的接口,以后的新系统只需要与外观接口交互即可。 构建多层系统时,可以使用外观模式来将系统分层,让外观接口成为每一层的入口,简化层间调用,给层间松耦。 团队协作时,可以将各自负责的模块建立合适的外观,简化其他同事的使用,节约沟通时间。 发布订阅者模式 发布 - 订阅模式(Publish-Subscribe Pattern, pub-sub)又叫观察者模式(Observer Pattern),属于行为型模式,它定义了一种一对多的关系,让多个订阅者对象同时监听某一个发布者,或者叫主题对象,这个主题对象的状态发生变化时就会通知所有订阅自己的订阅者对象,使得它们能够自动更新自己。 发布 - 订阅模式有几个主要概念: Publisher:发布者,当消息发生时负责通知对应订阅者 Subscriber:订阅者,当消息发生时被通知的对象 SubscriberMap:以type为主键存储数组,每个数组存储所有对应type的订阅者 type: 消息类型,订阅者可以订阅的不同消息类型 subscribe:该方法可以将订阅者添加到SubscriberMap中对应的数组中 unSubscribe:该方法为SubscriberMap中删除订阅者 notify:该方法遍历通知SubscriberMap中对应type的所有订阅者 代码示例 [代码]var Publisher = (function() { var _subsMap = {} // 存储订阅者 return { /* 消息订阅 */ subscribe(type, cb) { if(_subsMap[type]){ if (!_subsMap[type].includes(cb)){ _subsMap[type].push(cb); } }else{ _subsMap[type] = [cb]; } }, /* 消息退订 */ unsubscribe(type, cb) { if(!_subsMap[type] || !_subsMap[type].includes(cb))return; var idx = _subsMap[type].indexOf(cb); _subsMap[type].splice(idx, 1); }, /* 消息发布 */ notify(type) { if (!_subsMap[type])return; var args = Array.prototype.slice.call(arguments, 1); _subsMap[type].forEach(function(cb){ cb.apply(this, args); }) } } })() Publisher.subscribe('运动鞋', function(message){console.log('111' + message)}); // 订阅运动鞋 Publisher.subscribe('运动鞋', function(message){console.log('222' + message)}); Publisher.subscribe('帆布鞋', function(message){console.log('333' + message)}); // 订阅帆布鞋 Publisher.notify('运动鞋', ' 运动鞋到货了 ~') // 打电话通知买家运动鞋消息 Publisher.notify('帆布鞋', ' 帆布鞋售罄了 T.T') // 打电话通知买家帆布鞋消息 [代码] 这是一个发布-订阅模式的通用代码实现,Publisher就是一个发布者,这里使用了立即调用函数IIFE方式来将不希望被外界调用的_subsMap隐藏。订阅者采用回调函数的形式,在消息发布时使用JavaScript的apply、call函数使发布的消息参数可以传到订阅者回调函数中去。 源码中的发布-订阅模式 我们使用Jquery的API可以轻松实现消息的订阅、发布以及退订操作: [代码]function eventHandler() { console.log('自定义方法') } /* ---- 事件订阅 ---- */ $('#app').on('myevent', eventHandler) // 发布 $('#app').trigger('myevent') // 输出:自定义方法 /* ---- 取消订阅 ---- */ $('#app').off('myevent') $('#app').trigger('myevent') // 没有输出 [代码] 对应api源码参见: event.js 其中add方法为on接口的内部直接绑定方法,remove方法对应off接口的内部实现。 发布-订阅模式的优缺点 发布-订阅模式最大优点就是解耦: 时间上的解耦:注册事件后,订阅者不需要持续关注发布者的动态,当事件触发时,发布者会通知对应的订阅者,调用对应的回调函数。 对象间的解耦: 发布者不需要提前知道事件的订阅者有哪些,当事件发生时直接遍历对应的订阅者回调函数来通知订阅者,从而解耦了发布者和订阅者之间的联系,使它们之间互不持有。 发布-订阅模式也有一些缺点: 增加消耗:创建结构和缓存订阅者两个过程都会消耗计算和内存资源,即时订阅后没有触发过,订阅者使用会存在内存中。 增加复杂度 :订阅者被缓存在一起,如果多个订阅者和发布者层层嵌套,那么程序将变得难以追踪和调试,参考一下 Vue 调试的时候你点开原型链时看到的那堆 deps/subs/watchers 们… 总结 设计模式能够让我站在巨人的肩膀上,享受其他开发者们长期以来在一些有挑战性问题上的解决方案以及优秀的架构。 对我们来讲,知道有这些设计模式是很重要的,但更重要的是应该知道怎样以及什么时候去使用它们。遵守设计原则,使用设计模式是好事,但是过犹不及,在实际项目中我们不能刻板的遵守这些设计原则以及使用设计模式,在想使用每个模式前先去了解下它的优缺点。要真正的理解模式能给你带来什么好处需要花时间去尝试,以实际情况中模式给你的程序带来的好处作为标准来选择。
2019-10-31 - 小程序跳转页面加载优化
适应场景: 小程序页面跳转redirect/navigate/其它方式 分析: 从用户触发跳转行为到下一个页面onload生命周期函数内时间差会有500ms左右,如果在页面跳转之后进行onload函数内才开始去加载页面数据,那么这500ms左右的时间就浪费了。 改进: 在页面触发跳转行为的处理函数里结合promise预先加载下个页面的数据,并将promise对象缓存,此时页面跳转和加载数据同时进行,到了目标页面再取出缓存的promise对象进行判断和取数据操作。 效果: 跳转页面加载速度提高了600ms。 示例: 代码结构 [图片] pageManager.js [代码]// 写在utils里的公用方法 const pageList = {}; module.exports = { putData:function(pageName, data){ pageList[pageName] = data; }, getData:function(pageName){ return pageList[pageName]; } } [代码] util.js [代码]const myPromise = fn => obj => { return new Promise((resolve, reject) => { obj.complete = obj.success = (res) => { resolve(res); } obj.fail = (err) => { reject(err); } fn(obj); }) } module.exports = { myPromise : myPromise } [代码] index.js [代码]// 跳转页面 const {myPromise} = require('../../utils/util'); const pageManager = require('../../utils/pageManager'); page({ data: { }, onLoad:function(){ }, gotoPageA:function(){ const PromisePageA = myPromise(wx.request)({ url : '' }).then((res)=>{ return res.data; }) pageManager.putData('pageA',promisePageA); wx.navigateTo({ url: 'pages/pageA/pageA' }) } }) [代码] pageA.js [代码]// 被跳转页面 const util = require('../../utils/util.js'); const pageManager = require('../../utils/pageManager'); const {myPromise} = require('../../utils/util'); Page({ data:{ logs:[] }, onLoad: function(){ const promisePageA = pageManager.getData('pageA'); if(promisePageA){ const resData = promisePageA.then( function(data){ }, function(){ console.log("err"); } ) } } }) [代码]
2019-10-31 - Vue.js双向绑定原理和MVVM框架的简单实现
前言 由于近两年前端技术变革速度太快,vue不论针对web项目开发,网站制作,还是app,小程序开发,都越来越流行,其便捷性及易用程度都让你不得不考虑去学习。这里就围绕着Vue.js框架的双向数据绑定的实现原理进行一个剖析。 内容 前端主流MVVM框架双向绑定原理简述: [代码]1、AngularJs(脏检测机制): 脏检查机制,主要是依据 $watch 对象来监测数据是否更新。 2、Vue.js(前端数据对象劫持): Vue.js是通过数据劫持结合发布者-订阅者模式的方式来实现的。 3、React(手动触发绑定): React本身并没有提到双向绑定的概念,但是可以利用setState api对states数据进行更新,从而实现数据层于视图层的同步更新(结构更新,重新“渲染”子树(虚拟DOM),找出最小改动步骤,打包DOM操作,对真实DOM树进行修改)。 [代码] what is mvvm ? [代码]Model:数据模型,存储数据 View:带特殊属性、指令的 html 模板 ViewModel:依靠模板上面的指令,修改model数据后自动渲染view视图 [代码] Vue.js实现双向绑定的核心API: Object.defineProperty(),这个es5提供的API是实现双向绑定的核心,最主要的作用是重写数据的get、set方法,当我们访问或设置对象的属性的时候,都会触发相对应的函数动一些手脚做点我们自己想做的事情,实现“数据劫持”(发布者-订阅者模式)的效果。 详细用法 & 解释: https://www.jianshu.com/p/8fe1382ba135 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty 一个简单例子 [代码] let obj = { name: 'JesBrian' }; let age = 23; Object.defineProperty(obj, 'age', { configurable: false, // 是否可以删除属性 默认不能删除 enumerable: true, // 是否可以被枚举 默认是不能被枚举(遍历) // get,set 设置时不能设置 writable和value,要一对一对设置,交叉设置/同时存在会报错 // value: 23, // 设置属性的值 // writable: true, // 是否可以修改对象 get() { // 获取 obj.age 的时候就会调用get方法 getToDo(age); return age; }, set(val) { // 将值重新赋给 obj.age setToDo(val, age); age = val; } }); function getToDo (val) { console.log('get - ' + val); }; function setToDo (nVal, oVal) { console.log('set - nVal = ' + nVal + ', oVal = ' + oVal); }; console.log(obj.age); // 23 delete obj.age; // configurable设为false 删除无效 console.log(obj.age); // 23 obj.age = 24; console.log(obj.age); // 24 for (let key in obj) { // 默认情况下通过defineProperty定义的属性是不能被枚举(遍历)的,需要设置enumerable为true才可以 否则只能拿到singer 属性 console.log(key); // name, age } [代码] 实现的思路: 实现数据监听器Observer,用Object.defineProperty()重写数据的get、set,值更新就在set中通知订阅者更新数据。 实现模板编译Compile,深度遍历dom树,对每个元素节点的指令模板进行替换数据以及订阅数据。 实现Watch用于连接Observer和Compile,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图。 简单的实现一个MVVM框架: View 含有属性和指令的模板 [代码] <div id="app"> <input type="text" v-model="name"> <h3 v-bind="name"></h3> <input type="text" v-model="testData1"> <h3>{{ testData1 }}</h3> <input type="text" v-model="testData2"> <h3>{{ testData2 }}</h3> </div> [代码] 调用方法,初始化实例 [代码] window.onload = function () { var app = new MiniVue({ el: '#app', // dom data: { // 数据 testData1: 'Mini Vue', testData2: '双向绑定', name: 'JesBrian' } }) } [代码] 实现MiniVue类 [代码] function MiniVue(options = {}) { this.$options = options; // 配置挂载 this.$el = document.querySelector(options.el); // 获取挂载的dom元素 this._data = options.data; // 数据挂载 this._watcherTpl = {}; // watcher池 this._observer(this._data); // 传入数据,执行函数,重写数据的get set this._compile(this.$el); // 传入dom,执行函数,编译模板 发布订阅 }; [代码] MiniVue里具体实现的发布-订阅 [代码] // Watch类: // 1、在模板编译_compile()阶段发布订阅 // 2、在赋值操作的时候,更新视图 function Watcher(el, vm, val, attr) { this.el = el; // 指令对应的DOM元素 this.vm = vm; // myVue实例 this.val = val; // 指令对应的值 this.attr = attr; // dom获取值,如value获取input的值 / innerHTML获取dom的值 this.update(); // 更新视图 } Watcher.prototype.update = function () { this.el[this.attr] = this.vm._data[this.val]; // 获取data的最新值 赋值给dom 更新视图 } // 数据监听器_observer: // 用Object.defineProperty()遍历data重写所有属性的get set。然后在给对象的某个属性赋值的时候,就会触发set。在set中我们可以监听到数据的变化,然后就可以触发watch更新视图。 MiniVue.prototype._observer = function (obj) { var _this = this; Object.keys(obj).forEach(key => { // 遍历数据 _this._watcherTpl[key] = { // 每个数据的订阅池() _directives: [] }; var value = obj[key]; // 获取属性值 var watcherTpl = _this._watcherTpl[key]; // 数据的订阅池 Object.defineProperty(_this._data, key, { // 双向绑定最重要的部分 重写数据的set get configurable: true, // 可以删除 enumerable: true, // 可以遍历 get() { console.log(`${key}获取值:${value}`); return value; // 获取值的时候 直接返回 }, set(newVal) { // 改变值的时候 触发set console.log(`${key}更新:${newVal}`); if (value !== newVal) { value = newVal; for (let item of watcherTpl._directives) { // 遍历订阅池 item.update(); // 遍历所有订阅的地方(v-model+v-bind+{{}}) 触发this._compile()中发布的订阅Watcher 更新视图 } } } }) }) } [代码] MiniVue的_compile模板编译实现 [代码] // 首先是深度遍历dom树,遍历每个节点以及子节点。 // 将模板中的变量替换成数据,初始化渲染页面视图。 // 把指令绑定的属性添加到对应的订阅池中 // 一旦数据有变动,收到通知,更新视图。 MiniVue.prototype._compile = function (el) { var _this = this, nodes = el.children; // 获取要挂载app的dom for (var i = 0, len = nodes.length; i < len; i++) { // 遍历dom节点 var node = nodes[i]; if (node.children.length) { _this._compile(node); // 递归深度遍历 dom树 } // 如果有v-model属性,并且元素是input或者textarea,监听它的input事件 if (node.hasAttribute('v-model') && (node.tagName = 'INPUT' || node.tagName == 'TEXTAREA')) { node.addEventListener('input', (function (key) { var attVal = node.getAttribute('v-model'); // 获取v-model绑定的值 _this._watcherTpl[attVal]._directives.push(new Watcher( // 将dom替换成属性的数据并发布订阅在set的时候更新数据 node, _this, attVal, 'value' )); return function () { _this._data[attVal] = nodes[key].value; // input值改变的时候将新值赋给数据触发set 进而触发watch 更新视图 } })(i)); } if (node.hasAttribute('v-bind')) { // v-bind指令 var attrVal = node.getAttribute('v-bind'); // 绑定的data _this._watcherTpl[attrVal]._directives.push(new Watcher( // 将dom替换成属性的数据并发布订阅 在set的时候更新数据 node, _this, attrVal, 'innerHTML' )) } var reg = /\{\{\s*([^}]+\S)\s*\}\}/g, txt = node.textContent; // 正则匹配{{}} if (reg.test(txt)) { node.textContent = txt.replace(reg, (matched, placeholder) => { // matched匹配的文本节点包括{{}}, placeholder 是{{}}中间的属性名 var getName = _this._watcherTpl; // 所有绑定watch的数据 getName = getName[placeholder]; // 获取对应watch 数据的值 if (!getName._directives) { // 没有事件池, 创建事件池 getName._directives = []; } getName._directives.push(new Watcher( // 将dom替换成属性的数据并发布订阅在set的时候更新数据 node, _this, placeholder, 'innerHTML' )); return placeholder.split('.').reduce((val, key) => { return _this._data[key]; //获取数据的值触发get返回当前值 }, this.$el); }); } } } [代码] 扩展: ES6 新的 API Proxy MDN 详细文档 阮一峰es6要点总结——Proxy 为什么 Vue 3 要将 Object.defineProperty 转为使用 Proxy Object.defineProperty 无法检测数组的变化 Object.defineProperty 当对象属性增删的时候,是监控不到的 (Vue.$set()) 使用 Proxy 重构 _observer 原型方法 [代码] MiniVue.prototype._observer = function (obj) { var _this = this // 把代理器返回的对象存到 this._data 里面 for (let key in obj) { // 遍历数据 _this._watcherTpl[key] = { // 每个数据的订阅池() _directives: [] }; _this._data = new Proxy(obj, { set(target, key, value) { console.log(`${key}更新:${value}`); // 利用 Reflect 还原默认的赋值操作 let res = Reflect.set(target, key, value) var watcherTpl = _this._watcherTpl[key]; // 数据的订阅池 for (let item of watcherTpl._directives) { // 遍历订阅池 item.update(); // 遍历所有订阅的地方(v-model+v-bind+{{}}) 触发this._compile()中发布的订阅Watcher 更新视图 } return res } }) } } [代码] 总结: 很好,经过一轮的大费周折,我们终于简单的实现了一个MVVM的框架,成功的实现了双向数据绑定的效果。我们在日常使用轮子的时候不仅仅要会用,更重要的是知道这个轮子的实现原理,并且对其进行深入的探究,甚至是自己模仿思路重新造一个轮子。
2019-07-24 - 小程序同层渲染原理剖析
众所周知,小程序当中有一类特殊的内置组件——原生组件,这类组件有别于 WebView 渲染的内置组件,他们是交由原生客户端渲染的。原生组件作为 Webview 的补充,为小程序带来了更丰富的特性和更高的性能,但同时由于脱离 Webview 渲染也给开发者带来了不小的困扰。在小程序引入「同层渲染」之前,原生组件的层级总是最高,不受 [代码]z-index[代码] 属性的控制,无法与 [代码]view[代码]、[代码]image[代码] 等内置组件相互覆盖, [代码]cover-view[代码] 和 [代码]cover-image[代码] 组件的出现一定程度上缓解了覆盖的问题,同时为了让原生组件能被嵌套在 [代码]swiper[代码]、[代码]scroll-view[代码] 等容器内,小程序在过去也推出了一些临时的解决方案。但随着小程序生态的发展,开发者对原生组件的使用场景不断扩大,原生组件的这些问题也日趋显现,为了彻底解决原生组件带来的种种限制,我们对小程序原生组件进行了一次重构,引入了「同层渲染」。 相信已经有不少开发者已经在日常的小程序开发中使用了「同层渲染」的原生组件,那么究竟什么是「同层渲染」?它背后的实现原理是怎样的?它是解决原生组件限制的银弹吗?本文将会为你一一解答这些问题。 什么是「同层渲染」? 首先我们先来了解一下小程序原生组件的渲染原理。我们知道,小程序的内容大多是渲染在 WebView 上的,如果把 WebView 看成单独的一层,那么由系统自带的这些原生组件则位于另一个更高的层级。两个层级是完全独立的,因此无法简单地通过使用 [代码]z-index[代码] 控制原生组件和非原生组件之间的相对层级。正如下图所示,非原生组件位于 WebView 层,而原生组件及 [代码]cover-view[代码] 与 [代码]cover-image[代码] 则位于另一个较高的层级: [图片] 那么「同层渲染」顾名思义则是指通过一定的技术手段把原生组件直接渲染到 WebView 层级上,此时「原生组件层」已经不存在,原生组件此时已被直接挂载到 WebView 节点上。你几乎可以像使用非原生组件一样去使用「同层渲染」的原生组件,比如使用 [代码]view[代码]、[代码]image[代码] 覆盖原生组件、使用 [代码]z-index[代码] 指定原生组件的层级、把原生组件放置在 [代码]scroll-view[代码]、[代码]swiper[代码]、[代码]movable-view[代码] 等容器内,通过 [代码]WXSS[代码] 设置原生组件的样式等等。启用「同层渲染」之后的界面层级如下图所示: [图片] 「同层渲染」原理 你一定也想知道「同层渲染」背后究竟采用了什么技术。只有真正理解了「同层渲染」背后的机制,才能更高效地使用好这项能力。实际上,小程序的同层渲染在 iOS 和 Android 平台下的实现不同,因此下面分成两部分来分别介绍两个平台的实现方案。 iOS 端 小程序在 iOS 端使用 WKWebView 进行渲染的,WKWebView 在内部采用的是分层的方式进行渲染,它会将 WebKit 内核生成的 Compositing Layer(合成层)渲染成 iOS 上的一个 WKCompositingView,这是一个客户端原生的 View,不过可惜的是,内核一般会将多个 DOM 节点渲染到一个 Compositing Layer 上,因此合成层与 DOM 节点之间不存在一对一的映射关系。不过我们发现,当把一个 DOM 节点的 CSS 属性设置为 [代码]overflow: scroll[代码] (低版本需同时设置 [代码]-webkit-overflow-scrolling: touch[代码])之后,WKWebView 会为其生成一个 [代码]WKChildScrollView[代码],与 DOM 节点存在映射关系,这是一个原生的 [代码]UIScrollView[代码] 的子类,也就是说 WebView 里的滚动实际上是由真正的原生滚动组件来承载的。WKWebView 这么做是为了可以让 iOS 上的 WebView 滚动有更流畅的体验。虽说 [代码]WKChildScrollView[代码] 也是原生组件,但 WebKit 内核已经处理了它与其他 DOM 节点之间的层级关系,因此你可以直接使用 WXSS 控制层级而不必担心遮挡的问题。 小程序 iOS 端的「同层渲染」也正是基于 [代码]WKChildScrollView[代码] 实现的,原生组件在 attached 之后会直接挂载到预先创建好的 [代码]WKChildScrollView[代码] 容器下,大致的流程如下: 创建一个 DOM 节点并设置其 CSS 属性为 [代码]overflow: scroll[代码] 且 [代码]-webkit-overflow-scrolling: touch[代码]; 通知客户端查找到该 DOM 节点对应的原生 [代码]WKChildScrollView[代码] 组件; 将原生组件挂载到该 [代码]WKChildScrollView[代码] 节点上作为其子 View。 [图片] 通过上述流程,小程序的原生组件就被插入到 [代码]WKChildScrollView[代码] 了,也即是在 [代码]步骤1[代码] 创建的那个 DOM 节点对应的原生 ScrollView 的子节点。此时,修改这个 DOM 节点的样式属性同样也会应用到原生组件上。因此,「同层渲染」的原生组件与普通的内置组件表现并无二致。 Android 端 小程序在 Android 端采用 chromium 作为 WebView 渲染层,与 iOS 不同的是,Android 端的 WebView 是单独进行渲染而不会在客户端生成类似 iOS 那样的 Compositing View (合成层),经渲染后的 WebView 是一个完整的视图,因此需要采用其他的方案来实现「同层渲染」。经过我们的调研发现,chromium 支持 WebPlugin 机制,WebPlugin 是浏览器内核的一个插件机制,主要用来解析和描述embed 标签。Android 端的同层渲染就是基于 [代码]embed[代码] 标签结合 chromium 内核扩展来实现的。 [图片] Android 端「同层渲染」的大致流程如下: WebView 侧创建一个 [代码]embed[代码] DOM 节点并指定组件类型; chromium 内核会创建一个 [代码]WebPlugin[代码] 实例,并生成一个 [代码]RenderLayer[代码]; Android 客户端初始化一个对应的原生组件; Android 客户端将原生组件的画面绘制到步骤2创建的 [代码]RenderLayer[代码] 所绑定的 [代码]SurfaceTexture[代码] 上; 通知 chromium 内核渲染该 [代码]RenderLayer[代码]; chromium 渲染该 [代码]embed[代码] 节点并上屏。 [图片] 这样就实现了把一个原生组件渲染到 WebView 上,这个流程相当于给 WebView 添加了一个外置的插件,如果你有留意 Chrome 浏览器上的 pdf 预览,会发现实际上它也是基于 [代码]<embed />[代码] 标签实现的。 这种方式可以用于 map、video、canvas、camera 等原生组件的渲染,对于 input 和 textarea,采用的方案是直接对 chromium 的组件进行扩展,来支持一些 WebView 本身不具备的能力。 对比 iOS 端的实现,Android 端的「同层渲染」真正将原生组件视图加到了 WebView 的渲染流程中且 embed 节点是真正的 DOM 节点,理论上可以将任意 WXSS 属性作用在该节点上。Android 端相对来说是更加彻底的「同层渲染」,但相应的重构成本也会更高一些。 「同层渲染」 Tips 通过上文我们已经了解了「同层渲染」在 iOS 和 Android 端的实现原理。Android 端的「同层渲染」是基于 chromium 内核开发的扩展,可以看成是 webview 的一项能力,而 iOS 端则需要在使用过程中稍加注意。以下列出了若干注意事项,可以帮助你避免踩坑: Tips 1. 不是所有情况均会启用「同层渲染」 需要注意的是,原生组件的「同层渲染」能力可能会在特定情况下失效,一方面你需要在开发时稍加注意,另一方面同层渲染失败会触发 [代码]bindrendererror[代码] 事件,可在必要时根据该回调做好 UI 的 fallback。根据我们的统计,目前同层失败率很低,也不需要太过于担心。 对 Android 端来说,如果用户的设备没有微信自研的 [代码]chromium[代码] 内核,则会无法切换至「同层渲染」,此时会在组件初始化阶段触发 [代码]bindrendererror[代码]。而 iOS 端的情况会稍复杂一些:如果在基础库创建同层节点时,节点发生了 WXSS 变化从而引起 WebKit 内核重排,此时可能会出现同层失败的现象。解决方法:应尽量避免在原生组件上频繁修改节点的 WXSS 属性,尤其要尽量避免修改节点的 [代码]position[代码] 属性。如需对原生组件进行变换,强烈推荐使用 [代码]transform[代码] 而非修改节点的 [代码]position[代码] 属性。 Tips 2. iOS 「同层渲染」与 WebView 渲染稍有区别 上文我们已经了解了 iOS 端同层渲染的原理,实际上,WebKit 内核并不感知原生组件的存在,因此并非所有的 WXSS 属性都可以在原生组件上生效。一般来说,定位 (position / margin / padding) 、尺寸 (width / height) 、transform (scale / rotate / translate) 以及层级 (z-index) 相关的属性均可生效,在原生组件外部的属性 (如 shadow、border) 一般也会生效。但如需对组件做裁剪则可能会失败,例如:[代码]border-radius[代码] 属性应用在父节点不会产生圆角效果。 Tips 3. 「同层渲染」的事件机制 启用了「同层渲染」之后的原生组件相比于之前的区别是原生组件上的事件也会冒泡,意味着,一个原生组件或原生组件的子节点上的事件也会冒泡到其父节点上并触发父节点的事件监听,通常可以使用 [代码]catch[代码] 来阻止原生组件的事件冒泡。 Tips 4. 只有子节点才会进入全屏 有别于非同层渲染的原生组件,像 [代码]video[代码] 和 [代码]live-player[代码] 这类组件进入全屏时,只有其子节点会被显示。 [图片] 总结 阅读本文之后,相信你已经对小程序原生组件的「同层渲染」有了更深入的理解。同层渲染不仅解决了原生组件的层级问题,同时也让原生组件有了更丰富的展示和交互的能力。下表列出的原生组件都已经支持了「同层渲染」,其他组件( textarea、camera、webgl 及 input)也会在近期逐步上线。现在你就可以试试用「同层渲染」来优化你的小程序了。 支持同层渲染的原生组件 最低版本 video v2.4.0 map v2.7.0 canvas 2d(新接口) v2.9.0 live-player v2.9.1 live-pusher v2.9.1
2019-11-21