框架入门

MVC、MVP与MVVM

MVC

M(Model):数据保存

V(View):用户页面

C(Controller):业务逻辑

所有通信都是单向的。

  1. View传指令到Controller。
  2. Controller完成业务逻辑后,要求Model改变状态。
  3. Model将新的数据发送到View,用户得到反馈。

MVP

M(Model)是业务逻辑层,主要负责数据,网络请求等操作

V(View)是视图层,负责绘制UI元素、与用户进行交互

P(Presenter)是View与Model交互的中间纽带,处理与用户交互的逻辑

MVP模式将Controller改名为Presenter,同时改变了通信方向。

  1. 各部分之间的通信,都是双向的。
  2. View与Model不发生联系,都通过Presenter传递。
  3. View非常薄,不部署任何业务逻辑,称为“被动视图”,即没有任何主动性,而Presenter非常厚,所有业务逻辑都部署在那里。

MVVM

Model层代表数据模型,也可以在Model中定义数据修改和操作的业务逻辑;View代表UI组件,它负责将数据模型转换成UI展现出来,ViewModel是一个同步View和Model的对象。

在MVVM架构下,View和Model之间并没有直接的联系,而是通过ViewModel进行交互,Model和ViewModel之间的交互是双向的,因此View数据的变化会同步到Model中,而Model数据的变化也会立即反应到View上。

ViewModel通过双向数据绑定把View层和Model层连接了起来,而View和Model之间的同步工作完全是自动的,无需人为干涉,因此开发者只需关注业务逻辑,不需要手动操作DOM,不需要关注数据状态的同步问题,复杂的数据状态维护完全由MVVM来统一管理。

对于 MVVM 来说,其实最重要的并不是通过双向绑定或者其他的方式将 View 与 ViewModel 绑定起来,而是通过 ViewModel 将视图中的状态和用户的行为分离出一个抽象,这才是 MVVM 的精髓

虚拟DOM

虚拟DOM初探

相较于 DOM 来说,操作 JS 对象会快很多,并且我们也可以通过 JS 来模拟 DOM

const ul = {
  tag: 'ul',
  props: {
    class: 'list'
  },
  children: {
    tag: 'li',
    children: '1'
  }
}

上述代码对应的 DOM 就是

<ul class='list'>
  <li>1</li>
</ul>

那么既然 DOM 可以通过 JS 对象来模拟,反之也可以通过 JS 对象来渲染出对应的 DOM。

当然了,通过 JS 来模拟 DOM 并且渲染对应的 DOM 只是第一步,难点在于如何判断新旧两个 JS 对象的最小差异并且实现局部更新 DOM。

这就需要Diff算法了。

虚拟DOM真的能提升性能吗?

使用虚拟 DOM,在DOM 阶段操作少了通讯的确是变高效了,但代价是在 JS 阶段需要完成额外的工作(diff计算),这项额外的工作是需要耗时的!

虚拟DOM并不是说比原生DOM API的操作快,而是说不管数据怎么变化,都可以以最小的代价来进行更新 DOM。在每个点上,其实用手工的原生方法会比diff好很多。比如说仅仅是修改了一个属性,需要整体重绘吗?显然这不是虚拟DOM提出来的意义。框架的意义在于掩盖底层的 DOM 操作,用更声明式的方式来描述,从而让代码更容易维护。

diff算法

首先 DOM 是一个多叉树的结构,如果需要完整的对比两颗树的差异,那么需要的时间复杂度会是 O(n ^ 3),这个复杂度肯定是不能接受的。于是 React 团队优化了算法,实现了 O(n) 的复杂度来对比差异。 实现 O(n) 复杂度的关键就是只对比同层的节点,而不是跨层对比,这也是考虑到在实际业务中很少会去跨层的移动 DOM 元素。 所以判断差异的算法就分为了两步

  • 首先从上至下,从左往右遍历对象,也就是树的深度遍历,这一步中会给每个节点添加索引,便于最后渲染差异
  • 一旦节点有子元素,就去判断子元素是否有不同

在第一步算法中,需要判断新旧节点的 tagName 是否相同,如果不相同的话就代表节点被替换了。如果没有更改 tagName 的话,就需要判断是否有子元素,有的话就进行第二步算法。

在第二步算法中,需要判断原本的列表中是否有节点被移除,在新的列表中需要判断是否有新的节点加入,还需要判断节点是否有移动。

举个例子来说,假设页面中只有一个列表,我们对列表中的元素进行了变更

// 假设这里模拟一个 ul,其中包含了 5 个 li
[1, 2, 3, 4, 5]
// 这里替换上面的 li
[1, 2, 5, 4]

从上述例子中,我们一眼就可以看出先前的 ul 中的第三个 li 被移除了,四五替换了位置。

那么在实际的算法中,我们如何去识别改动的是哪个节点呢?这就引入了 key 这个属性。这个属性是用来给每一个节点打标志的,用于判断是否是同一个节点。

当然在判断以上差异的过程中,我们还需要判断节点的属性是否有变化等等。

当我们判断出以上的差异后,就可以把这些差异记录下来。当对比完两棵树以后,就可以通过差异去局部更新 DOM,实现性能的最优化。

双向绑定

利用Object.defineProperty()对数据进行劫持,设置一个***Observer,用来监听所有属性,如果属性上发生变化了,就需要告诉订阅者Watcher去更新数据,最后指令解析器Compile解析对应的指令,进而会执行对应的更新函数,从而更新视图,实现双向绑定。

前端路由

前端路由的本质是监听 URL 的变化,然后匹配路由规则,显示相应的页面,并且无须刷新页面。目前前端使用的路由就只有两种实现方式

  • Hash 模式
  • History 模式

Hash 模式

www.test.com/#/ 就是 Hash URL,当 # 后面的哈希值发生变化时,可以通过 hashchange 事件来监听到 URL 的变化,从而进行跳转页面,并且无论哈希值如何变化,服务端接收到的 URL 请求永远是 www.test.com

window.addEventListener('hashchange', () => {
  // ... 具体逻辑
})

Hash 模式相对来说更简单,并且兼容性也更好。

History 模式

History 模式是 HTML5 新推出的功能,主要使用 history.pushStatehistory.replaceState 改变 URL。

通过 History 模式改变 URL 同样不会引起页面的刷新,只会更新浏览器的历史记录。

// 新增历史记录
history.pushState(stateObject, title, URL)
// 替换当前历史记录
history.replaceState(stateObject, title, URL)

当用户做出浏览器动作时,比如点击后退按钮时会触发 popState 事件

window.addEventListener('popstate', e => {
  // e.state 就是 pushState(stateObject) 中的 stateObject
  console.log(e.state)
})

两种模式对比

  • Hash 模式只可以更改 # 后面的内容,History 模式可以通过 API 设置任意的同源 URL
  • History 模式可以通过 API 添加任意类型的数据到历史记录中,Hash 模式只能更改哈希值,也就是字符串
  • Hash 模式无需后端配置,并且兼容性好。History 模式在用户手动输入地址或者刷新页面的时候会发起 URL 请求,后端需要配置 index.html 页面用于匹配不到静态资源的时候.