很多年以前, 我们写网页的时候都是这样的: 根据设计稿写好一个页面的 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 提供了内置的专用的模板引擎, 有指令, 过滤器, 插值表达式等功能, 有内置的指令过滤器, 也可以注册自己扩展的指令过滤器. 而且提供了 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 标准, 组件化已经是一种必然的趋势, 我们不仅要会去设计, 封装组件, 更要去了解组件的发展的前世今生, 这样才不会在框架的海洋中迷失.
来源: http://www.jianshu.com/p/4dc7d316718e