组件化的前世今生
很多年以前,我们写网页的时候都是这样的:根据设计稿写好一个页面的html和css,然后再去写js来做一些交互。如果遇到同样功能的代码,最简单粗暴的方式是复制粘贴,如果为了更好的复用性,就封装个jquery的插件,需要用的时候就引入插件,调用初始化的方法,传入参数,比如一个日历、一个轮播图。在那个web野蛮生长的年代,这样的插件产生了很多,那个时代的前端工程师必须会自定义jquery的插件。那时候也有一些组件库,比如extjs、bootstrap、jquery ui等。
但是这种组件的方案或者说jquery本身就有很多问题:
浏览器端效率最低的就是dom操作,因为会触发reflow,repaint,jquery是操作dom的一个库,基于jquery封装的插件当然也避免不了频繁的操作dom,所以这样的的插件如果代码写的时候不注意,效率很可能会比较低。
jquery只是一个库,而不是决定代码组织方式的框架,没有固定的代码规范,每个人都会有自己的编码风格,虽然可以规定一些规范,但毕竟不是强制的。如果团队成员,项目规模比较小的时候还好,随着项目、团队规模的扩大,这样的代码会越来越难以维护和复用。
现在的组件化的方案已经在那个时代的基础上前进了很大一步。
一些常见的逻辑,我们会把他们封装成函数或者类,比如BaseXxx、XxxUtils,牵扯到ui的组件复用的不只是逻辑,还有模板和样式。也就是说一个组件需要封装的就是关联的html、css、js。
我们可以先想想如果我们自己去做一个组件化的框架,我们会怎么做(主要考虑如何设计)。
如何去设计一个组件化的框架
模板,样式,交互逻辑
组件最基础的就是这三部分。样式我们可以不做封装,通过全局引入然后加个命名空间的方式来区分组件。模板可以挂载到dom树上通过选择器来取,或者直接传入一段模板字符串。交互逻辑的部分,我们会通过事件绑定调用组件上的一些方法。
class Component{ constructor({el,template,onXxx}){ this.el = el; this.template = template; this.onXxx = onXxx; this.render(); this.bindEvents(); } render(){ var ele = document.querySelector(this.el); ele.innerHTML = this.template; } bindEvents(){ this.el.querySelector('xx').addEventListener('click', this.onXxx) } }
现在我们的组件有了最初的模型,模板,逻辑,事件绑定,可以传参数来进行一些定制
。
模板引擎
现在我们把需要把数据填充到模板需要用拼接字符串的方式,这样的代码写起来很是繁琐,针对这个问题,已经有了成熟的解决方案,我们可以选用某一个模板引擎,像ejs,jsmart,jade之类的。但是我们需要的是一个能和我们的组件结合紧密的一个模板引擎,我们需要自己实现一个,这样,我们可以直接直接取组件中的数据,调用组件的某个方法,甚至自己扩展一些模板的功能。
比如,我们如果想实现这样一个模板引擎,
<table> <my:forEach items="goodsList" var="goods"> <td>${goods.name}</td> <td>${goods.price}</td> <td>${goods.amount}</td> </my:forEach> </table>
看上去是不是比较像jsp的语法,其实jsp就是一个专用的模板引擎,他有page,session,application,request,response等隐式对象,可以直接取几个域中的数据,而且也可以支持自定义标签和自定义el函数。
想想该怎么实现。一种思路是通过xml的解析,xml解析方式有dom和sax两种,就是分析出有什么标签有什么属性。然后对应的属性做什么操作。属性和对应操作我们给封装起来,叫做指令。开发者可以自己去注册一些自定义的指令。模板在解析的时候解析出对应的属性就会执行对应的操作。
通过模板解析的方式来初始化
我们组件用的时候,需要new一个组件的对象,传入需要的参数。比如:
new Component({ template:"<div><h1>title</h1><p>content</p></div>", onXxx: function(){} });
想一下,我们如果想不通过js来初始化,想通过下面这种方式来初始化该怎么做,
<Component template="xxxx" onXxx=""></Component>
我们之前自己实现了一个模板引擎,除了自定义指令的解析,当然也会把自定义组件的解析加进去。这样一棵组件树,我们只需要调用一次初始化方法,然后在解析组件树模板的过程中,把一个个组件初始化,组装好。这一些都是用户感知不到的,用户只需要写模板。
双向绑定MVVM
现在我们的组件还是避免不了要大量的操作dom,这必定会有很多的性能问题。能不能把dom操作也给封装起来,开发者不需要再去操作dom,只需要管理好数据就可以了呢。
想一下后端开发,最频繁的就是增删改查,这样的sql语句是经常要写的,于是后端有了orm框架,比如hibernate,映射好实体类和数据表,类的属性和字段的关系之后,只需要调用hibernate提供的Session类的增删改查的方法就好了,sql语句会自动生成,比如mybatis,映射好方法和写在xml中的sql语句的关系,之后只要调用对应的方法就可以了,不需要自己去写sql语句。
数据库中的表和java的实体类建立了映射关系就能够做到开发时不需要写sql语句,那么我们建立好数据和dom,也就是model和view之间的关系是不是也就可以不写任何一句dom操作的代码,只去管理数据呢,然后view会自动同步呢。
当然是可以的,从model到view的绑定,我们可以监听model的变化,变化的时候就去通知view中的Observer,然后那个Observer去操作dom,去更新视图。
监听model的变化,很容易想到的是es5中的Object.defineProperty这个api,他可以定义set方法,拦截对对象属性的赋值操作。
//观察者的队列 var observers = []; observers.push(new Observer({...})); var obj = {}; var value = ""; Object.defineProperty(obj, 'name', { get: function() { return value; }, set: function(val) { value = val; //数据改变,通知观察者,去更新view var target = this; observers.forEach(function(observer,index){ observer.notify(target); }); } });
当然es6提供的Proxy这个更高层次的封装类也可以。
// 观察者的队列 var observers = []; observers.push(new Observer({...})); let obj = {}; let proxy = new Proxy(obj, { get: function (target, key, receiver) { return Reflect.get(target, key, receiver); }, set: function (target, key, value, receiver) { Reflect.set(target, key, value, receiver); for(let observer in observers){ observer.notify(this); } } })
至于从view到model的绑定,其实就是监听用户输入的一些操作,监听表单的事件,然后去根据用户输入的数据和映射关系,去同步model。
生命周期函数
我们把dom操作给封装了,也就是把dom元素的增删改给自动化了,组件对应的dom元素的创建和销毁或者是重新绘制更新dom的时候,想做一些操作,就不能做了,所以我们要在这些时刻暴露一些钩子,让开发者可以在这些时候去做一些操作。比如组件的dom初次渲染完的时候要去请求数据,比如组件销毁的时候要做一些资源释放的工作避免内存泄漏等。主要的生命周期钩子函数就这么四类,创建前后,挂载到dom前后,更新前后,从dom中移除(销毁)前后。
生命周期的名字可以叫beforeCreate
,created
,beforeMount
,mounted
,beforeUpdate
,updated
,beforeDestroy
,destroyed
,
也可以叫componentWillMount
,componentDidMount
,componentWillUpdate
,componentDidUpdate
,componentWillUnmount
,componentDidUnmount
等。
虚拟dom和diff算法
现在我们的组件渲染是直接渲染到dom元素,并且是全局的渲染。model改变不大的时候,也会全局重新渲染一次,会有很多不必要的dom操作,性能损耗。我们知道,计算机领域很多问题都可以加一个中间层来解决,这里也一样,我们可以不直接渲染到真实dom元素,用js对象来模拟真实dom元素,每次渲染渲染成这样的一颗虚拟dom元素组成的树。
{ name: 'a', props: { }, children: [ { name: 'a-1', props:{}, children:[] }, { name: 'a-2', props:{}, children:[] }, { name: 'a-3', props:{}, children:[] } ] }
这样可以把上一次的渲染结果保留,下次渲染的时候和上一次的渲染结果做对比,比较有没有变化,有变化的话找出变化的部分,局部增量的渲染改变的部分。这样能避免不必要的dom操作带来的性能开销。比较的过程我们可以叫他diff算法。
引入了虚拟dom这一层,虽然会增大计算量和内存消耗,但是却减少了大量的dom操作。性能会有明显的提升。
Immutable
我们会在model变化以后去更新view,但是model有没有变化需要和之前的model做对比,model是一个对象,可能层次比较深,深层的比较是比较慢的,这里又会有性能的问题。针对这一问题,我们应该怎么去优化呢?
我们都知道字符串是常量。jvm的内存空间分为堆、栈、方法区、静态域4个部分,方法区中有个字符串常量池,来存放字符串。也就是我们创建一个字符串,如果常量池中有的话,他会直接把引用返回给你,如果没有的话会创建一个字符串然后放入常量池中。对字符串的修改会创建一个新的字符串,而不是直接修改原字符串。编程语言基本都是这样处理字符串的,好处也是很明显的,设想一下,如果有一个长度为1000的字符串,要和另一个字符串做比较,那么如果字符串不是常量,那么完成比较就要要遍历字符串的每一个字符,复杂度为o(n)。但如果我们把字符串设计为常量,比较时只需要比较两个字符串的内存地址,那么复杂度就降到了o(1)。这种优化的思路是典型的空间换时间。
组件的model我们也可以实现为不可变(immutable)的,这样比较的时候只需要比较两个model的引用就可以了,会使性能又有一个大的提高。
fiber
想一想我们的组件化框架还有哪里有问题。
我们知道浏览器中每个页面是单线程的,渲染和js计算共用一个线程,会相互阻塞。
model改变后要生成虚拟dom,生成虚拟dom、虚拟dom之间的diff可能会计算比较长的时间,如果这时候页面上有个动画在同时抢占着主线程,那么势必会导致动画的卡顿。每个痛点的解决,都能会带来性能的提升,为了追求极致的性能,这个问题我们也要想办法解决。
虚拟dom是一颗树形的结构,生成或比较一般都是递归的过程。我们知道所有的递归都可以改成循环的方式,只要我们可以一个队列来保存中间状态。把递归改成循环后,就可以异步化分段执行了。先执行一段计算,然后把执行状态保存,释放主线程去做渲染,渲染完之后再去做之后的计算。这样就完美的解决了浏览器环境下计算和渲染之间相互阻塞的问题了,性能有了进一步的提升。
这种资源的竞争在计算机中随处可见,就像cpu的进程调度,每个进程的计算都要用到cpu,操作系统就需要用一种合理的方式来分配cpu资源。cpu调度策略有很多几种,比如分时,按照优先级等等,都是把一个大的计算量给分成多次来执行,暂停执行的时候把上下文信息保存下来,得到cpu的时候再恢复上下文继续执行。
计算量分段,类似切菜,我们把这种调度策略叫fiber,即纤维化。
没有fiber之前的虚拟dom计算是这样的
fiber之后是这样的
完美解决了浏览器的单线程下单次计算量过大会阻塞渲染的问题。
Component-Native
之前为了减少不必要的渲染,我们加了个中间层-虚拟dom,除了可以带来性能的提示之外,我们可以有一些别的思考,比如我可不可以不只渲染成dom元素,渲染成安卓、ios原生的组件?
经过思考,我们觉得这是可行的,逻辑依然用js来写,通过jscore来执行js,js需要调用的原生api由框架封装,提供给js。渲染部分,建立原生组件和和模板中组件的映射关系,渲染的时候生成对应的原生组件。逻辑的部分可以复用,除了渲染的是原生的组件,别的功能依然都有。
思路是可行的,但是实现这些组件、提供供js调用的原生api,工作量肯定比较大,而且会有很多坑。
全局状态管理
组件之间可以通过传递参数来通信。如果只是父子组件通信比较简单,但是如果需要通信的两个组件之间间隔的层次比较多,或者是兄弟组件,那么之间互相通信就很麻烦了,需要多层的传递或者是通过父组件做中转。针对这个问题,有没有什么别的思路呢?
其实可以引入一个中介者来解决,就像婚姻中介,如果男方自己去找女方,或者女方自己去找男方都不太方便,这时候可以找一个中介,男方和女方分别在那里注册自己的信息,然后等中介有消息的时候通知自己。这样男方和女方就不需要相互联系,只要和婚姻中介联系就可以了。
类似的,我们可以创建一个store来存储全局的信息,组件在store那里注册,当一个组件向store发送消息的时候,监听store的组件就能收到消息,从store中取出变化后的数据。
其他
关于组件的想象空间还有很大。未来可能会能够渲染到所有的端,渲染过程中的每一个环节,每一个痛点都有相应的优化方案。性能、功能都可以不断地提升。只要我们不要停止思考、停止敲代码的双手。
现在主流的组件化的框架
我们从jquery插件出发,思考了很多我们想要的组件化框架的样子,回到现实,我们看一下现在主流的组件化的框架有哪些,他们各自都有哪些特性。
react
react支持jsx的语法,可以html和js混着写,而不像模板引擎,需要去另外学习一套模板的语法。
有了jsx,可以直接用
ReactDOM.render( <MyComponent values="xxx"></MyComponent>, document.getElementById("container") )
通过解析jsx来初始化,而不需要手动去new一个组件对象。
react提供了从model到view的单向的绑定,state发生了变化,就会去render
react也提供了完善的生命周期函数供开发者在组件创建、更新、销毁前后进扩展一些功能。而且提供了componentWillReceiveProps和shouldComponentUpdate两个用于优化性能的生命周期函数。
componentWillReceiveProps是在组件接收到新的props,还没有render之前调用,在这里去调用setState更新状态,不会触发额外的render。shouldComponentUpdate是在state或props变化之后调用的,根据返回的结果决定是不是调用render, 可以和Immutable.js结合,来避免state的深层比较带来的性能损耗。。
react 有虚拟dom这一层,并且会通过优化到的o(n)的diff算法来进行虚拟dom的对比。
react是reconsiler(调度者),react-dom是renderer。react 16使用了fiber这个新的调度算法。使得大计算量被拆解,提高了应用的可访问性和性能。
react-native提供了可以渲染成安卓、ios组件的renderer,同时提供了原生的api供js调用。
可以结合redux来做状态管理
vue
vue提供了内置的专用的模板引擎,有指令、过滤器、插值表达式等功能,有内置的指令过滤器,也可以注册自己扩展的指令过滤器。而且提供了render函数,可以结合babel来实现jsx的编译。
vue提供了双向绑定MVVM
vue有完善的生命周期函数,包括create前后,mount前后,update前后和destory前后
vue2.x加入了虚拟dom,可以减少不必要的渲染
vue社区有weex这个做原生渲染的框架
vue可以结合vuex来做全局状态管理
angular2
支持模板的语法,指令、过滤器、插值表达式
decorator的方式来声明组件
支持IOC
支持组件化
支持双向绑定MVVM
创建、更新、销毁前后的生命周期函数
和typescript结合紧密
其他组件化的框架
实现组件化的框架很多,比如Avalon、Ember、Konckout等等,都有各自的特点
WebComponents
组件化是一个趋势,现在有很多实现组件化的框架,W3C提出了web compoenents的标准:。这个标准主要由4种技术组成,html import、shadow dom、custom elment和html template。新的标准肯定会有兼容性的问题,goole推出了Polymer
这个基于web components规范的组件化框架。
总结
从最开始的jquery插件,到现在的各种组件化的框架、web components标准,组件化已经是一种必然的趋势,我们不仅要会去设计、封装组件,更要去了解组件的发展的前世今生,这样才不会在框架的海洋中迷失。
作者:_神说要有光_
链接:https://www.jianshu.com/p/4dc7d316718e
共同学习,写下你的评论
评论加载中...
作者其他优质文章