Vue 是如何检测数组和对象变化的?
通过代理原型的方式实现。
怎么实现代码原型
1 | methodsToPatch.forEach(function(method) { |
正如该题所问,vue对push,pop,splice等方法进行了hack,hack方式很简单,如果加入新对象,对新对象进行响应式化,至于如何响应式化请参考vue源码。
举例来说对于push和unshift会推入一个新的对象到数组里(不管从前还是从后),记录这个加入的对象,并调用Observe方法将加入的对象转换成响应式对象,对于splice方法,如果加入了新对象也是将该对象响应式化。
最后一步是向外抛出数组变化,提醒观察者进行更新。
为什么要对数组单独处理
在Vue现有阶段中,对响应式处理利用的是Object.defineProperty对数据进行拦截,而这个方法并不能监听到数组内部变化,数组长度变化,数组的截取变化等,所以我们需要对这些操作进行hack,让vue能监听到其中的变化。
存在问题
对于Object.defineProperty的缺陷导致如果直接改变数组下标是无法hack的,由于此点,vue提供了$set方法,最新的解决方案当然是利用Proxy对象进行监听,但是Proxy的缺陷在于兼容性,可能会为了性能以及便利而放弃兼容性吧,一切都要看尤大的决定了。
webpack 热更新原理
1.当修改了一个或多个文件;
2.文件系统接收更改并通知webpack;
3.webpack重新编译构建一个或多个模块,并通知HMR服务器进行更新;
4.HMR Server 使用webSocket通知HMR runtime 需要更新,HMR运行时通过HTTP请求更新jsonp;
5.HMR运行时替换更新中的模块,如果确定这些模块无法更新,则触发整个页面刷新。
关于HMP:
双向绑定和 vuex 是否冲突?
当在严格模式中使用 Vuex 时,在属于 Vuex 的 state 上使用 v-model 会比较棘手:
1 | <input v-model="obj.message"> |
假设这里的 obj 是在计算属性中返回的一个属于 Vuex store 的对象,在用户输入时,v-model 会试图直接修改 obj.message。在严格模式中,由于这个修改不是在 mutation 函数中执行的, 这里会抛出一个错误。 用“Vuex 的思维”去解决这个问题的方法是:给 中绑定 value,然后侦听 input 或者 change 事件,在事件回调中调用一个方法:
1 | <input :value="message" @input="updateMessage"> |
下面是 mutation 函数:
1 | // ... |
#双向绑定的计算属性
必须承认,这样做比简单地使用“v-model + 局部状态”要啰嗦得多,并且也损失了一些 v-model 中很有用的特性。另一个方法是使用带有 setter 的双向绑定计算属性:
1 | <input v-model="message"> |
webpack 打包 vue 速度太慢怎么办?
1.使用webpack-bundle-analyzer对项目进行模块分析生成report,查看report后看看哪些模块体积过大,然后针对性优化,比如项目中引用了常用的UI库element-ui和v-charts等
2.配置webpack的externals ,官方文档的解释:防止将某些import的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖。
所以,可以将体积大的库分离出来:
1 | // ... |
3.然后在main.js中移除相关库的import
4.在index.html模板文件中,添加相关库的cdn引用,如:
1 | <script src="https://unpkg.com/element-ui@2.10.0/lib/index.js"></script> |
vue 如何优化首页的加载速度?vue 首页白屏是什么问题引起的?如何解决呢?
首先分析原因
VUE首页加载过慢,其原因是因为它是一个单页应用,需要将所有需要的资源都下载到浏览器端并解析。
考虑解决办法
- 1.使用首屏SSR + 跳转SPA方式来优化
- 2.改单页应用为多页应用,需要修改webpack的entry
- 3.改成多页以后使用应该使用prefetch的就使用
- 4.处理加载的时间片,合理安排加载顺序,尽量不要有大面积空隙
- 5.CDN资源还是很重要的,最好分开,也能减少一些不必要的资源损耗
- 6.使用Quicklink,在网速好的时候 可以帮助你预加载页面资源
- 7.骨架屏这种的用户体验的东西一定要上,最好借助stream先将这部分输出给浏览器解析
- 8.合理使用web worker优化一些计算
React 和 Vue 的 diff 时间复杂度从 O(n^3) 优化到 O(n) ,那么 O(n^3) 和 O(n) 是如何计算出来的?
这里的n指的是页面的VDOM节点数,这个不太严谨。如果更严谨一点,我们应该应该假设
变化之前的节点数为m,变化之后的节点数为n。
React 和 Vue 做优化的前提是 “放弃了最优解”,本质上是一种权衡,有利有弊。
倘若这个算法用到别的行业,比如医药行业,肯定是不行的,为什么?
React 和 Vue 做的假设是:
- 检测VDOM的变化只发生在同一层
- 检测VDOM的变化依赖于用户指定的key
如果变化发生在不同层或者同样的元素用户指定了不同的key或者不同元素用户指定同样的key,React 和 Vue都不会检测到,就会发生莫名其妙的问题。
但是React 认为, 前端碰到上面的第一种情况概率很小,第二种情况又可以通过提示用户,让用户去解决,因此
这个取舍是值得的。 没有牺牲空间复杂度,却换来了在大多数情况下时间上的巨大提升,明智的选择!
基本概念
首先大家要有个基本概念。其实这是一个典型的最小编辑距离的问题,相关算法有很多,比如Git中,提交之前会进行一次对象的diff操作,就是用的这个最小距离编辑算法。
leetcode 有原题目如果想明白这个O(n^3), 可以先看下这个。
对于树,我们也是一样的,我们定义三种操作,用来将一棵树转化为另外一棵树:
删除 删除一个节点,将它的children交给它的父节点
插入 在children中 插入一个节点
修改 修改节点的值
事实上,从一棵树转化为另外一棵树,我们有很多方式,我们要找到最少的。
直观的方式是用动态规划,通过这种记忆化搜索减少时间复杂度。
算法
由于树是一种递归的数据结构,因此最简单的树的比较算法是递归处理。
详细描述这个算法可以写一篇很长的论文,这里不赘述。
大家想看代码的,这里有一份
确切地说,树的最小距离编辑算法的时间复杂度是O(n^2m(1+logmn)),
我们假设m 与 n 同阶, 就会变成 O(n^3)。
Vue 的响应式原理中 Object.defineProperty 有什么缺陷?
- Object.defineProperty无法监控到数组下标的变化,导致通过数组下标添加元素,不能实时响应;
- Object.defineProperty只能劫持对象的属性,从而需要对每个对象,每个属性进行遍历,如果,属性值是对象,还需要深度遍历。Proxy可以劫持整个对象,并返回一个新的对象。
- Proxy不仅可以代理对象,还可以代理数组。还可以代理动态增加的属性。
Virtual DOM 真的比操作原生 DOM 快吗?谈谈你的想法。
1. 原生 DOM 操作 vs. 通过框架封装操作。
这是一个性能 vs. 可维护性的取舍。框架的意义在于为你掩盖底层的 DOM 操作,让你用更声明式的方式来描述你的目的,从而让你的代码更容易维护。没有任何框架可以比纯手动的优化 DOM 操作更快,因为框架的 DOM 操作层需要应对任何上层 API 可能产生的操作,它的实现必须是普适的。针对任何一个 benchmark,我都可以写出比任何框架更快的手动优化,但是那有什么意义呢?在构建一个实际应用的时候,你难道为每一个地方都去做手动优化吗?出于可维护性的考虑,这显然不可能。框架给你的保证是,你在不需要手动优化的情况下,我依然可以给你提供过得去的性能。
2. 对 React 的 Virtual DOM 的误解。
React 从来没有说过 “React 比原生操作 DOM 快”。React 的基本思维模式是每次有变动就整个重新渲染整个应用。如果没有 Virtual DOM,简单来想就是直接重置 innerHTML。很多人都没有意识到,在一个大型列表所有数据都变了的情况下,重置 innerHTML 其实是一个还算合理的操作… 真正的问题是在 “全部重新渲染” 的思维模式下,即使只有一行数据变了,它也需要重置整个 innerHTML,这时候显然就有大量的浪费。
我们可以比较一下 innerHTML vs. Virtual DOM 的重绘性能消耗:
- innerHTML: render html string O(template size) + 重新创建所有 DOM 元素 O(DOM size)
- Virtual DOM: render Virtual DOM + diff O(template size) + 必要的 DOM 更新 O(DOM change)
Virtual DOM render + diff 显然比渲染 html 字符串要慢,但是!它依然是纯 js 层面的计算,比起后面的 DOM 操作来说,依然便宜了太多。可以看到,innerHTML 的总计算量不管是 js 计算还是 DOM 操作都是和整个界面的大小相关,但 Virtual DOM 的计算量里面,只有 js 计算和界面大小相关,DOM 操作是和数据的变动量相关的。前面说了,和 DOM 操作比起来,js 计算是极其便宜的。这才是为什么要有 Virtual DOM:它保证了 1)不管你的数据变化多少,每次重绘的性能都可以接受;2) 你依然可以用类似 innerHTML 的思路去写你的应用。
3. MVVM vs. Virtual DOM
相比起 React,其他 MVVM 系框架比如 Angular, Knockout 以及 Vue、Avalon 采用的都是数据绑定:通过 Directive/Binding 对象,观察数据变化并保留对实际 DOM 元素的引用,当有数据变化时进行对应的操作。MVVM 的变化检查是数据层面的,而 React 的检查是 DOM 结构层面的。MVVM 的性能也根据变动检测的实现原理有所不同:Angular 的脏检查使得任何变动都有固定的
O(watcher count) 的代价;Knockout/Vue/Avalon 都采用了依赖收集,在 js 和 DOM 层面都是 O(change):
- 脏检查:scope digest O(watcher count) + 必要 DOM 更新 O(DOM change)
- 依赖收集:重新收集依赖 O(data change) + 必要 DOM 更新 O(DOM change)可以看到,Angular 最不效率的地方在于任何小变动都有的和 watcher 数量相关的性能代价。但是!当所有数据都变了的时候,Angular 其实并不吃亏。依赖收集在初始化和数据变化的时候都需要重新收集依赖,这个代价在小量更新的时候几乎可以忽略,但在数据量庞大的时候也会产生一定的消耗。
MVVM 渲染列表的时候,由于每一行都有自己的数据作用域,所以通常都是每一行有一个对应的 ViewModel 实例,或者是一个稍微轻量一些的利用原型继承的 “scope” 对象,但也有一定的代价。所以,MVVM 列表渲染的初始化几乎一定比 React 慢,因为创建 ViewModel / scope 实例比起 Virtual DOM 来说要昂贵很多。这里所有 MVVM 实现的一个共同问题就是在列表渲染的数据源变动时,尤其是当数据是全新的对象时,如何有效地复用已经创建的 ViewModel 实例和 DOM 元素。假如没有任何复用方面的优化,由于数据是 “全新” 的,MVVM 实际上需要销毁之前的所有实例,重新创建所有实例,最后再进行一次渲染!这就是为什么题目里链接的 angular/knockout 实现都相对比较慢。相比之下,React 的变动检查由于是 DOM 结构层面的,即使是全新的数据,只要最后渲染结果没变,那么就不需要做无用功。
Angular 和 Vue 都提供了列表重绘的优化机制,也就是 “提示” 框架如何有效地复用实例和 DOM 元素。比如数据库里的同一个对象,在两次前端 API 调用里面会成为不同的对象,但是它们依然有一样的 uid。这时候你就可以提示 track by uid 来让 Angular 知道,这两个对象其实是同一份数据。那么原来这份数据对应的实例和 DOM 元素都可以复用,只需要更新变动了的部分。或者,你也可以直接 track by $index 来进行 “原地复用”:直接根据在数组里的位置进行复用。在题目给出的例子里,如果 angular 实现加上 track by $index 的话,后续重绘是不会比 React 慢多少的。甚至在 dbmonster 测试中,Angular 和 Vue 用了 track by $index 以后都比 React 快: dbmon (注意 Angular 默认版本无优化,优化过的在下面)
顺道说一句,React 渲染列表的时候也需要提供 key 这个特殊 prop,本质上和 track-by 是一回事。
4. 性能比较也要看场合
在比较性能的时候,要分清楚初始渲染、小量数据更新、大量数据更新这些不同的场合。Virtual DOM、脏检查 MVVM、数据收集 MVVM 在不同场合各有不同的表现和不同的优化需求。Virtual DOM 为了提升小量数据更新时的性能,也需要针对性的优化,比如 shouldComponentUpdate 或是 immutable data。
- 初始渲染:Virtual DOM > 脏检查 >= 依赖收集
- 小量数据更新:依赖收集 >> Virtual DOM + 优化 > 脏检查(无法优化) > Virtual DOM 无优化
- 大量数据更新:脏检查 + 优化 >= 依赖收集 + 优化 > Virtual DOM(无法/无需优化)>> MVVM 无优化
不要天真地以为 Virtual DOM 就是快,diff 不是免费的,batching 么 MVVM 也能做,而且最终 patch 的时候还不是要用原生 API。在我看来 Virtual DOM 真正的价值从来都不是性能,而是它 1) 为函数式的 UI 编程方式打开了大门;2) 可以渲染到 DOM 以外的 backend,比如 ReactNative。
5. 总结
以上这些比较,更多的是对于框架开发研究者提供一些参考。主流的框架 + 合理的优化,足以应对绝大部分应用的性能需求。如果是对性能有极致需求的特殊情况,其实应该牺牲一些可维护性采取手动优化:比如 Atom 编辑器在文件渲染的实现上放弃了 React 而采用了自己实现的 tile-based rendering;又比如在移动端需要 DOM-pooling 的虚拟滚动,不需要考虑顺序变化,可以绕过框架的内置实现自己搞一个。
聊聊 Redux 和 Vuex 的设计思想。
不管是Vue,还是 React,都需要管理状态(state),比如组件之间都有共享状态的需要。什么是共享状态?比如一个组件需要使用另一个组件的状态,或者一个组件需要改变另一个组件的状态,都是共享状态。
父子组件之间,兄弟组件之间共享状态,往往需要写很多没有必要的代码,比如把状态提升到父组件里,或者给兄弟组件写一个父组件,听听就觉得挺啰嗦。
如果不对状态进行有效的管理,状态在什么时候,由于什么原因,如何变化就会不受控制,就很难跟踪和测试了。如果没有经历过这方面的困扰,可以简单理解为会搞得很乱就对了。
在软件开发里,有些通用的思想,比如隔离变化,约定优于配置等,隔离变化就是说做好抽象,把一些容易变化的地方找到共性,隔离出来,不要去影响其他的代码。约定优于配置就是很多东西我们不一定要写一大堆的配置,比如我们几个人约定,view 文件夹里只能放视图,不能放过滤器,过滤器必须放到 filter 文件夹里,那这就是一种约定,约定好之后,我们就不用写一大堆配置文件了,我们要找所有的视图,直接从 view 文件夹里找就行。
根据这些思想,对于状态管理的解决思路就是:把组件之间需要共享的状态抽取出来,遵循特定的约定,统一来管理,让状态的变化可以预测。根据这个思路,产生了很多的模式和库,我们来挨个聊聊。
Store 模式
最简单的处理就是把状态存到一个外部变量里面,比如:this.$root.$data, 当然也可以是一个全局变量。但是这样有一个问题,就是数据改变后,不会留下变更过的记录,这样不利于调试。
所以我们稍微搞得复杂一点,用一个简单的 Store 模式:
1 | var store = { |
store 的 state 来存数据,store 里面有一堆的 action,这些 action 来控制 state 的改变,也就是不直接去对 state 做改变,而是通过 action 来改变,因为都走 action,我们就可以知道到底改变(mutation)是如何被触发的,出现错误,也可以记录记录日志啥的。
不过这里没有限制组件里面不能修改 store 里面的 state,万一组件瞎胡修改,不通过 action,那我们也没法跟踪这些修改是怎么发生的。所以就需要规定一下,组件不允许直接修改属于 store 实例的 state,组件必须通过 action 来改变 state,也就是说,组件里面应该执行 action 来分发 (dispatch) 事件通知 store 去改变。这样约定的好处是,我们能够记录所有 store 中发生的 state 改变,同时实现能做到记录变更 (mutation)、保存状态快照、历史回滚/时光旅行的先进的调试工具。
这样进化了一下,一个简单的 Flux 架构就实现了。
Flux
Flux其实是一种思想,就像MVC,MVVM之类的,他给出了一些基本概念,所有的框架都可以根据他的思想来做一些实现。
Flux把一个应用分成了4个部分: View Action Dispatcher Store
比如我们搞一个应用,显而易见,这个应用里面会有一堆的 View,这个 View 可以是Vue的,也可以是 React的,啥框架都行,啥技术都行。
View 肯定是要展示数据的,所谓的数据,就是 Store,Store 很容易明白,就是存数据的地方。当然我们可以把 Store 都放到一起,也可以分开来放,所以就有一堆的 Store。但是这些 View 都有一个特点,就是 Store 变了得跟着变。
View 怎么跟着变呢?一般 Store 一旦发生改变,都会往外面发送一个事件,比如 change,通知所有的订阅者。View 通过订阅也好,监听也好,不同的框架有不同的技术,反正 Store 变了,View 就会变。
View 不是光用来看的,一般都会有用户操作,用户点个按钮,改个表单啥的,就需要修改 Store。Flux 要求,View 要想修改 Store,必须经过一套流程,有点像我们刚才 Store 模式里面说的那样。视图先要告诉 Dispatcher,让 Dispatcher dispatch 一个 action,Dispatcher 就像是个中转站,收到 View 发出的 action,然后转发给 Store。比如新建一个用户,View 会发出一个叫 addUser 的 action 通过 Dispatcher 来转发,Dispatcher 会把 addUser 这个 action 发给所有的 store,store 就会触发 addUser 这个 action,来更新数据。数据一更新,那么 View 也就跟着更新了。
这个过程有几个需要注意的点: Dispatcher 的作用是接收所有的 Action,然后发给所有的 Store。这里的 Action 可能是 View 触发的,也有可能是其他地方触发的,比如测试用例。转发的话也不是转发给某个 Store,而是所有 Store。 Store 的改变只能通过 Action,不能通过其他方式。也就是说 Store 不应该有公开的 Setter,所有 Setter 都应该是私有的,只能有公开的 Getter。具体 Action 的处理逻辑一般放在 Store 里。
听听描述看看图,可以发现,Flux的最大特点就是数据都是单向流动的。
Redux
Flux 有一些缺点(特点),比如一个应用可以拥有多个 Store,多个Store之间可能有依赖关系;Store 封装了数据还有处理数据的逻辑。
所以大家在使用的时候,一般会用 Redux,他和 Flux 思想比较类似,也有差别。
Store
Redux 里面只有一个 Store,整个应用的数据都在这个大 Store 里面。Store 的 State 不能直接修改,每次只能返回一个新的 State。Redux 整了一个 createStore 函数来生成 Store。
1 | import { createStore } from 'redux'; |
Store 允许使用 store.subscribe 方法设置监听函数,一旦 State 发生变化,就自动执行这个函数。这样不管 View 是用什么实现的,只要把 View 的更新函数 subscribe 一下,就可以实现 State 变化之后,View 自动渲染了。比如在 React 里,把组件的render方法或setState方法订阅进去就行。
Action
和 Flux 一样,Redux 里面也有 Action,Action 就是 View 发出的通知,告诉 Store State 要改变。Action 必须有一个 type 属性,代表 Action 的名称,其他可以设置一堆属性,作为参数供 State 变更时参考。
1 | const action = { |
Redux 可以用 Action Creator 批量来生成一些 Action。
Reducer
Redux 没有 Dispatcher 的概念,Store 里面已经集成了 dispatch 方法。store.dispatch()是 View 发出 Action 的唯一方法。
1 | import { createStore } from 'redux'; |
Redux 用一个叫做 Reducer 的纯函数来处理事件。Store 收到 Action 以后,必须给出一个新的 State(就是刚才说的Store 的 State 不能直接修改,每次只能返回一个新的 State),这样 View 才会发生变化。这种 State 的计算过程就叫做 Reducer。
什么是纯函数呢,就是说没有任何的副作用,比如这样一个函数:
1 | function getAge(user) { |
这个函数就有副作用,每一次相同的输入,都可能导致不同的输出,而且还会影响输入 user 的值,再比如:
1 | let b = 10; |
这个函数也有副作用,就是依赖外部的环境,b 在别处被改变了,返回值对于相同的 a 就有可能不一样。
而 Reducer 是一个纯函数,对于相同的输入,永远都只会有相同的输出,不会影响外部的变量,也不会被外部变量影响,不得改写参数。它的作用大概就是这样,根据应用的状态和当前的 action 推导出新的 state:
1 | (previousState, action) => newState |
类比 Flux,Flux 有些像:
1 | (state, action) => state |
为什么叫做 Reducer 呢?reduce 是一个函数式编程的概念,经常和 map 放在一起说,简单来说,map 就是映射,reduce 就是归纳。映射就是把一个列表按照一定规则映射成另一个列表,而 reduce 是把一个列表通过一定规则进行合并,也可以理解为对初始值进行一系列的操作,返回一个新的值。
比如 Array 就有一个方法叫 reduce,Array.prototype.reduce(reducer, ?initialValue),把 Array 整吧整吧弄成一个 newValue。
1 | const array1 = [1, 2, 3, 4]; |
看起来和 Redux 的 Reducer 是不是好像好像,Redux 的 Reducer 就是 reduce 一个列表(action的列表)和一个 initialValue(初始的 State)到一个新的 value(新的 State)。
把上面的概念连起来,举个例子:
下面的代码声明了 reducer:
1 | const defaultState = 0; |
createStore接受 Reducer 作为参数,生成一个新的 Store。以后每当store.dispatch发送过来一个新的 Action,就会自动调用 Reducer,得到新的 State。
1 | import { createStore } from 'redux'; |
createStore 内部干了什么事儿呢?通过一个简单的 createStore 的实现,可以了解大概的原理(可以略过不看):
1 | const createStore = (reducer) => { |
Redux 有很多的 Reducer,对于大型应用来说,State 必然十分庞大,导致 Reducer 函数也十分庞大,所以需要做拆分。Redux 里每一个 Reducer 负责维护 State 树里面的一部分数据,多个 Reducer 可以通过 combineReducers 方法合成一个根 Reducer,这个根 Reducer 负责维护整个 State。
1 | import { combineReducers } from 'redux'; |
combineReducers 干了什么事儿呢?通过简单的 combineReducers 的实现,可以了解大概的原理(可以略过不看):
1 | const combineReducers = reducers => { |
流程
再回顾一下刚才的流程图,尝试走一遍 Redux 流程:
1、用户通过 View 发出 Action:
1 | store.dispatch(action); |
2、然后 Store 自动调用 Reducer,并且传入两个参数:当前 State 和收到的 Action。 Reducer 会返回新的 State 。
1 | let nextState = xxxReducer(previousState, action); |
3、State 一旦有变化,Store 就会调用监听函数。
1 | store.subscribe(listener); |
4、listener可以通过 store.getState() 得到当前状态。如果使用的是 React,这时可以触发重新渲染 View。
1 | function listerner() { |
对比 Flux
和 Flux 比较一下:Flux 中 Store 是各自为战的,每个 Store 只对对应的 View 负责,每次更新都只通知对应的View:
Redux 中各子 Reducer 都是由根 Reducer 统一管理的,每个子 Reducer 的变化都要经过根 Reducer 的整合:
简单来说,Redux有三大原则: 单一数据源:Flux 的数据源可以是多个。 State 是只读的:Flux 的 State 可以随便改。 * 使用纯函数来执行修改:Flux 执行修改的不一定是纯函数。
Redux 和 Flux 一样都是单向数据流。
中间件
刚才说到的都是比较理想的同步状态。在实际项目中,一般都会有同步和异步操作,所以 Flux、Redux 之类的思想,最终都要落地到同步异步的处理中来。
在 Redux 中,同步的表现就是:Action 发出以后,Reducer 立即算出 State。那么异步的表现就是:Action 发出以后,过一段时间再执行 Reducer。
那怎么才能 Reducer 在异步操作结束后自动执行呢?Redux 引入了中间件 Middleware 的概念。
其实我们重新回顾一下刚才的流程,可以发现每一个步骤都很纯粹,都不太适合加入异步的操作,比如 Reducer,纯函数,肯定不能承担异步操作,那样会被外部IO干扰。Action呢,就是一个纯对象,放不了操作。那想来想去,只能在 View 里发送 Action 的时候,加上一些异步操作了。比如下面的代码,给原来的 dispatch 方法包裹了一层,加上了一些日志打印的功能:
1 | let next = store.dispatch; |
既然能加日志打印,当然也能加入异步操作。所以中间件简单来说,就是对 store.dispatch 方法进行一些改造的函数。不展开说了,所以如果想详细了解中间件,可以点这里。
Redux 提供了一个 applyMiddleware 方法来应用中间件:
1 | const store = createStore( |
这个方法主要就是把所有的中间件组成一个数组,依次执行。也就是说,任何被发送到 store 的 action 现在都会经过thunk,promise,logger 这几个中间件了。
处理异步
对于异步操作来说,有两个非常关键的时刻:发起请求的时刻,和接收到响应的时刻(可能成功,也可能失败或者超时),这两个时刻都可能会更改应用的 state。一般是这样一个过程:
- 请求开始时,dispatch 一个请求开始 Action,触发 State 更新为“正在请求”状态,View 重新渲染,比如展现个Loading啥的。
- 请求结束后,如果成功,dispatch 一个请求成功 Action,隐藏掉 Loading,把新的数据更新到 State;如果失败,dispatch 一个请求失败 Action,隐藏掉 Loading,给个失败提示。
显然,用 Redux 处理异步,可以自己写中间件来处理,当然大多数人会选择一些现成的支持异步处理的中间件。比如 redux-thunk 或 redux-promise 。
Redux-thunk
thunk 比较简单,没有做太多的封装,把大部分自主权交给了用户:
1 | const createFetchDataAction = function(id) { |
缺点就是用户要写的代码有点多,可以看到上面的代码比较啰嗦,一个请求就要搞这么一套东西。
Redux-promise
redus-promise 和 redux-thunk 的思想类似,只不过做了一些简化,成功失败手动 dispatch 被封装成自动了:
1 | const FETCH_DATA = 'FETCH_DATA' |
刚才的什么 then、catch 之类的被中间件自行处理了,代码简单不少,不过要处理 Loading 啥的,还需要写额外的代码。
其实任何时候都是这样:封装少,自由度高,但是代码就会变复杂;封装多,代码变简单了,但是自由度就会变差。redux-thunk 和 redux-promise 刚好就是代表这两个面。
redux-thunk 和 redux-promise 的具体使用就不介绍了,这里只聊一下大概的思路。大部分简单的异步业务场景,redux-thunk 或者 redux-promise 都可以满足了。
上面说的 Flux 和 Redux,和具体的前端框架没有什么关系,只是思想和约定层面。下面就要和我们常用的 Vue 或 React 结合起来了:
Vuex
Vuex 主要用于 Vue,和 Flux,Redux 的思想很类似。
Store
每一个 Vuex 里面有一个全局的 Store,包含着应用中的状态 State,这个 State 只是需要在组件中共享的数据,不用放所有的 State,没必要。这个 State 是单一的,和 Redux 类似,所以,一个应用仅会包含一个 Store 实例。单一状态树的好处是能够直接地定位任一特定的状态片段,在调试的过程中也能轻易地取得整个当前应用状态的快照。
Vuex通过 store 选项,把 state 注入到了整个应用中,这样子组件能通过 this.$store 访问到 state 了。
1 | const app = new Vue({ |
State 改变,View 就会跟着改变,这个改变利用的是 Vue 的响应式机制。
Mutation
显而易见,State 不能直接改,需要通过一个约定的方式,这个方式在 Vuex 里面叫做 mutation,更改 Vuex 的 store 中的状态的唯一方法是提交 mutation。Vuex 中的 mutation 非常类似于事件:每个 mutation 都有一个字符串的 事件类型 (type) 和 一个 回调函数 (handler)。
1 | const store = new Vuex.Store({ |
触发 mutation 事件的方式不是直接调用,比如 increment(state) 是不行的,而要通过 store.commit 方法:
1 | store.commit('increment') |
注意:mutation 都是同步事务。
mutation 有些类似 Redux 的 Reducer,但是 Vuex 不要求每次都搞一个新的 State,可以直接修改 State,这块儿又和 Flux 有些类似。具尤大的说法,Redux 强制的 immutability,在保证了每一次状态变化都能追踪的情况下强制的 immutability 带来的收益很有限,为了同构而设计的 API 很繁琐,必须依赖第三方库才能相对高效率地获得状态树的局部状态,这些都是 Redux 不足的地方,所以也被 Vuex 舍掉了。
到这里,其实可以感觉到 Flux、Redux、Vuex 三个的思想都差不多,在具体细节上有一些差异,总的来说都是让 View 通过某种方式触发 Store 的事件或方法,Store 的事件或方法对 State 进行修改或返回一个新的 State,State 改变之后,View 发生响应式改变。
Action
到这里又该处理异步这块儿了。mutation 是必须同步的,这个很好理解,和之前的 reducer 类似,不同步修改的话,会很难调试,不知道改变什么时候发生,也很难确定先后顺序,A、B两个 mutation,调用顺序可能是 A -> B,但是最终改变 State 的结果可能是 B -> A。
对比Redux的中间件,Vuex 加入了 Action 这个东西来处理异步,Vuex的想法是把同步和异步拆分开,异步操作想咋搞咋搞,但是不要干扰了同步操作。View 通过 store.dispatch(‘increment’) 来触发某个 Action,Action 里面不管执行多少异步操作,完事之后都通过 store.commit(‘increment’) 来触发 mutation,一个 Action 里面可以触发多个 mutation。所以 Vuex 的Action 类似于一个灵活好用的中间件。
Vuex 把同步和异步操作通过 mutation 和 Action 来分开处理,是一种方式。但不代表是唯一的方式,还有很多方式,比如就不用 Action,而是在应用内部调用异步请求,请求完毕直接 commit mutation,当然也可以。
Vuex 还引入了 Getter,这个可有可无,只不过是方便计算属性的复用。
Vuex 单一状态树并不影响模块化,把 State 拆了,最后组合在一起就行。Vuex 引入了 Module 的概念,每个 Module 有自己的 state、mutation、action、getter,其实就是把一个大的 Store 拆开。
总的来看,Vuex 的方式比较清晰,适合 Vue 的思想,在实际开发中也比较方便。
对比Redux
Redux: view——>actions——>reducer——>state变化——>view变化(同步异步一样)
Vuex: view——>commit——>mutations——>state变化——>view变化(同步操作) view——>dispatch——>actions——>mutations——>state变化——>view变化(异步操作)
React-redux
Redux 和 Flux 类似,只是一种思想或者规范,它和 React 之间没有关系。Redux 支持 React、Angular、Ember、jQuery 甚至纯 JavaScript。
但是因为 React 包含函数式的思想,也是单向数据流,和 Redux 很搭,所以一般都用 Redux 来进行状态管理。为了简单处理 Redux 和 React UI 的绑定,一般通过一个叫 react-redux 的库和 React 配合使用,这个是 react 官方出的(如果不用 react-redux,那么手动处理 Redux 和 UI 的绑定,需要写很多重复的代码,很容易出错,而且有很多 UI 渲染逻辑的优化不一定能处理好)。
Redux将React组件分为容器型组件和展示型组件,容器型组件一般通过connect函数生成,它订阅了全局状态的变化,通过mapStateToProps函数,可以对全局状态进行过滤,而展示型组件不直接从global state获取数据,其数据来源于父组件。
如果一个组件既需要UI呈现,又需要业务逻辑处理,那就得拆,拆成一个容器组件包着一个展示组件。
因为 react-redux 只是 redux 和 react 结合的一种实现,除了刚才说的组件拆分,并没有什么新奇的东西,所以只拿一个简单TODO项目的部分代码来举例:
入口文件 index.js,把 redux 的相关 store、reducer 通过 Provider 注册到 App 里面,这样子组件就可以拿到 store 了。
1 | import React from 'react' |
actions/index.js,创建 Action:
1 | let nextTodoId = 0 |
reducers/todos.js,创建 Reducers:
1 | const todos = (state = [], action) => { |
reducers/index.js,把所有的 Reducers 绑定到一起:
1 | import { combineReducers } from 'redux' |
containers/VisibleTodoList.js,容器组件,connect 负责连接React组件和Redux Store:
1 | import { connect } from 'react-redux' |
简单来说,react-redux 就是多了个 connect 方法连接容器组件和UI组件,这里的“连接”就是一种映射: mapStateToProps 把容器组件的 state 映射到UI组件的 props mapDispatchToProps 把UI组件的事件映射到 dispatch 方法
Redux-saga
刚才介绍了两个Redux 处理异步的中间件 redux-thunk 和 redux-promise,当然 redux 的异步中间件还有很多,他们可以处理大部分场景,这些中间件的思想基本上都是把异步请求部分放在了 action creator 中,理解起来比较简单。
redux-saga 采用了另外一种思路,它没有把异步操作放在 action creator 中,也没有去处理 reductor,而是把所有的异步操作看成“线程”,可以通过普通的action去触发它,当操作完成时也会触发action作为输出。saga 的意思本来就是一连串的事件。
redux-saga 把异步获取数据这类的操作都叫做副作用(Side Effect),它的目标就是把这些副作用管理好,让他们执行更高效,测试更简单,在处理故障时更容易。
在聊 redux-saga 之前,需要熟悉一些预备知识,那就是 ES6 的 Generator。
如果从没接触过 Generator 的话,看着下面的代码,给你个1分钟傻瓜式速成,函数加个星号就是 Generator 函数了,Generator 就是个骂街生成器,Generator 函数里可以写一堆 yield 关键字,可以记成“丫的”,Generator 函数执行的时候,啥都不干,就等着调用 next 方法,按照顺序把标记为“丫的”的地方一个一个拎出来骂(遍历执行),骂到最后没有“丫的”标记了,就返回最后的return值,然后标记为 done: true,也就是骂完了(上面只是帮助初学者记忆,别喷~)。
1 | function* helloWorldGenerator() { |
这样搞有啥好处呢?我们发现 Generator 函数的很多代码可以被延缓执行,也就是具备了暂停和记忆的功能:遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值,等着下一次调用next方法时,再继续往下执行。用 Generator 来写异步代码,大概长这样:
1 | function* gen(){ |
再回到 redux-saga 来,可以把 saga 想象成开了一个以最快速度不断地调用 next 方法并尝试获取所有 yield 表达式值的线程。举个例子:
1 | // saga.js |
这里用了好几个yield,简单理解,也就是每个 yield 都发起了阻塞,saga 会等待执行结果返回,再执行下一指令。也就是相当于take、put、call、put 这几个方法的调用变成了同步的,上面的全部完成返回了,才会执行下面的,类似于 await。
用了 saga,我们就可以很细粒度的控制各个副作用每一部的操作,可以把异步操作和同步发起 action 一起,随便的排列组合。saga 还提供 takeEvery、takeLatest 之类的辅助函数,来控制是否允许多个异步请求同时执行,尤其是 takeLatest,方便处理由于网络延迟造成的多次请求数据冲突或混乱的问题。
saga 看起来很复杂,主要原因可能是因为大家不熟悉 Generator 的语法,还有需要学习一堆新增的 API 。如果抛开这些记忆的东西,改造一下,再来看一下代码:
1 | function mySaga(){ |
上面的代码就很清晰了吧,全部都是同步的写法,无比顺畅,当然直接这样写是不支持的,所以那些 Generator 语法和API,无非就是做一些适配而已。
saga 还能很方便的并行执行异步任务,或者让两个异步任务竞争:
1 | // 并行执行,并等待所有的结果,类似 Promise.all 的行为 |
saga 的每一步都可以做一些断言(assert)之类的,所以非常方便测试。而且很容易测试到不同的分支。
这里不讨论更多 saga 的细节,大家了解 saga 的思想就行,细节请看文档。
对比 Redux-thunk
比较一下 redux-thunk 和 redux-saga 的代码:
和 redux-thunk 等其他异步中间件对比来说,redux-saga 主要有下面几个特点: 异步数据获取的相关业务逻辑放在了单独的 saga.js 中,不再是掺杂在 action.js 或 component.js 中。 dispatch 的参数是标准的 action,没有魔法。 saga 代码采用类似同步的方式书写,代码变得更易读。 代码异常/请求失败 都可以直接通过 try/catch 语法直接捕获处理。 * 很容易测试,如果是 thunk 的 Promise,测试的话就需要不停的 mock 不同的数据。
其实 redux-saga 是用一些学习的复杂度,换来了代码的高可维护性,还是很值得在项目中使用的。
Dva
Dva是什么呢?官方的定义是:dva 首先是一个基于 redux 和 redux-saga 的数据流方案,然后为了简化开发体验,dva 还额外内置了 react-router 和 fetch,所以也可以理解为一个轻量级的应用框架。
简单理解,就是让使用 react-redux 和 redux-saga 编写的代码组织起来更合理,维护起来更方便。
之前我们聊了 redux、react-redux、redux-saga 之类的概念,大家肯定觉得头昏脑涨的,什么 action、reducer、saga 之类的,写一个功能要在这些js文件里面不停的切换。
dva 做的事情很简单,就是让这些东西可以写到一起,不用分开来写了。比如:
1 | app.model({ |
以前书写的方式是创建 sagas/products.js, reducers/products.js 和 actions/products.js,然后把 saga、action、reducer 啥的分开来写,来回切换,现在写在一起就方便多了。
比如传统的 TODO 应用,用 redux + redux-saga 来表示结构,就是这样:
saga 拦截 add 这个 action, 发起 http 请求, 如果请求成功, 则继续向 reducer 发一个 addTodoSuccess 的 action, 提示创建成功, 反之则发送 addTodoFail 的 action 即可。
如果使用 Dva,那么结构图如下:
整个结构变化不大,最主要的就是把 store 及 saga 统一为一个 model 的概念(有点类似 Vuex 的 Module),写在了一个 js 文件里。增加了一个 Subscriptions, 用于收集其他来源的 action,比如快捷键操作。
1 | app.model({ |
之前我们说过约定优于配置的思想,Dva正式借鉴了这个思想。
MobX
前面扯了这么多,其实还都是 Flux 体系的,都是单向数据流方案。接下来要说的 MobX,就和他们不太一样了。
我们先清空一下大脑,回到初心,什么是初心?就是我们最初要解决的问题是什么?最初我们其实为了解决应用状态管理的问题,不管是 Redux 还是 MobX,把状态管理好是前提。什么叫把状态管理好,简单来说就是:统一维护公共的应用状态,以统一并且可控的方式更新状态,状态更新后,View跟着更新。不管是什么思想,达成这个目标就ok。
Flux 体系的状态管理方式,只是一个选项,但并不代表是唯一的选项。MobX 就是另一个选项。
MobX背后的哲学很简单:任何源自应用状态的东西都应该自动地获得。译成人话就是状态只要一变,其他用到状态的地方就都跟着自动变。
看这篇文章的人,大概率会对面向对象的思想比较熟悉,而对函数式编程的思想略陌生。Flux 或者说 Redux 的思想主要就是函数式编程(FP)的思想,所以学习起来会觉得累一些。而 MobX 更接近于面向对象编程,它把 state 包装成可观察的对象,这个对象会驱动各种改变。什么是可观察?就是 MobX 老大哥在看着 state 呢。state 只要一改变,所有用到它的地方就都跟着改变了。这样整个 View 可以被 state 来驱动。
1 | const obj = observable({ |
上面的obj,他的 obj.a 属性被使用了,那么只要 obj.a 属性一变,所有使用的地方都会被调用。autoRun 就是这个老大哥,他看着所有依赖 obj.a 的地方,也就是收集所有对 obj.a 的依赖。当 obj.a 改变时,老大哥就会触发所有依赖去更新。
MobX 允许有多个 store,而且这些 store 里的 state 可以直接修改,不用像 Redux 那样每次还返回个新的。这个有点像 Vuex,自由度更高,写的代码更少。不过它也会让代码不好维护。
MobX 和 Flux、Redux 一样,都是和具体的前端框架无关的,也就是说可以用于 React(mobx-react) 或者 Vue(mobx-vue)。一般来说,用到 React 比较常见,很少用于 Vue,因为 Vuex 本身就类似 MobX,很灵活。如果我们把 MobX 用于 React 或者 Vue,可以看到很多 setState() 和 http://this.state.xxx = 这样的处理都可以省了。
还是和上面一样,只介绍思想。具体 MobX 的使用,可以看这里。
对比 Redux
我们直观地上两坨实现计数器代码:
Redux:
1 | import React, { Component } from 'react'; |
MobX:
1 | import React, { Component } from 'react'; |
比较一下:
- Redux 数据流流动很自然,可以充分利用时间回溯的特征,增强业务的可预测性;MobX 没有那么自然的数据流动,也没有时间回溯的能力,但是 View 更新很精确,粒度控制很细。
- Redux 通过引入一些中间件来处理副作用;MobX 没有中间件,副作用的处理比较自由,比如依靠 autorunAsync 之类的方法。
- Redux 的样板代码更多,看起来就像是我们要做顿饭,需要先买个调料盒装调料,再买个架子放刀叉。。。做一大堆准备工作,然后才开始炒菜;而 MobX 基本没啥多余代码,直接硬来,拿着炊具调料就开干,搞出来为止。
但其实 Redux 和 MobX 并没有孰优孰劣,Redux 比 Mobx 更多的样板代码,是因为特定的设计约束。如果项目比较小的话,使用 MobX 会比较灵活,但是大型项目,像 MobX 这样没有约束,没有最佳实践的方式,会造成代码很难维护,各有利弊。一般来说,小项目建议 MobX 就够了,大项目还是用 Redux 比较合适。
写 React / Vue 项目时为什么要在列表组件中写 key,其作用是什么?
key 的作用是为了在 diff 算法执行时更快的找到对应的节点,提高 diff 速度。
vue 和 react 都是采用 diff 算法来对比新旧虚拟节点,从而更新节点。在 Vue 的 diff 函数中。可以先了解一下 diff 算法。
在交叉对比的时候,当新节点跟旧节点头尾交叉对比没有结果的时候,会根据新节点的 key 去对比旧节点数组中的 key,从而找到相应旧节点(这里对应的是一个 key => index 的 map 映射)。如果没找到就认为是一个新增节点。而如果没有 key,那么就会采用一种遍历查找的方式去找到对应的旧节点。一种一个 map 映射,另一种是遍历查找。相比而言。map 映射的速度更快。
Vue 部分源码如下:
1 | // vue src/core/vdom/patch.js |
创建 map 函数:
1 | function createKeyToOldIdx (children, beginIdx, endIdx) { |
遍历寻找:
1 | // sameVnode 是对比新旧节点是否相同的函数 |
SPA(单页面应用)和MPA(多页面应用)
模式示意图
单页面应用
第一次进入页面时会请求一个
html
文件,刷新清除一下,切换到其他组件,此时路径也相应变化,但是并没有新的html
文件请求,页面内容却变化了。
原理: js
会感知到url
的变化,通过这一点可以用js
动态地将当前页面的内容清除,然后将下一个页面的内容挂载到当前页面上。这个时候的路由不再是后端来做了,而是前端来做,判断页面显示相应的组件,清除不需要的。
页面跳转: js渲染
优点: 页面切换快
缺点: 首屏时间稍慢,SEO差
1. 为什么页面切换快?
页面每次切换跳转时,并不需要处理html
文件的请求,这样就节约了很多HTTP
发送时延,所以我们在切换页面的时候速度很快。
2. 为什么首屏时间慢,SEO 差?
单页应用的首屏时间慢,首屏时需要请求一次html
,同时还要发送一次js
请求,两次请求回来了,首屏才会展示出来。相对于多页面应用,首屏时间慢。
SEO效果差,因为搜索引擎只认识html
里的内容,不认识js
渲染生成的内容,搜索引擎不识别,也就不会给一个好排名,会导致单页应用做出来的网页在搜索引擎上的排名差。
3. 为什么还要用 Vue 呢?
Vue
官方提供了一些其他的技术来解决这些缺点,比如服务端渲染技术(SSR),通过这些技术可以完美解决这些缺点,这样一来单页面应用对于前端来说就是非常完美的页面开发解决方案了。
多页面应用
每一次页面跳转的时候,后台服务器都会返回一个新的
html
文档,这种类型的网站也就是多页网站,也叫多页应用。
页面跳转: 返回HTML
优点: 首屏时间快,SEO效果好
缺点: 页面切换慢
1. 为什么多页应用的首屏时间快?
首屏时间叫做页面首个屏幕的内容展现的时间,当我们访问页面的时候,服务器返回一个html
,页面就会展示出来,这个过程只经历了一个HTTP
请求,所以页面展示的速度非常快。
2. 为什么搜索引擎优化效果好(SEO)?
搜索引擎在做网页排名的时候,要根据网页的内容才能给网页权重,来进行网页的排名。搜索引擎是可以识别html
内容的,而我们每个页面所有的内容都放在html
中,所以这种多页应用SEO排名效果好。
3. 缺点:切换慢
每次跳转都需要发送一个HTTP
请求,如果网络状态不好,在页面间来回跳转时,就会发生明显的卡顿,影响用户体验。
总结
/ | 多页面应用模式MPA | 单页面应用模式SPA |
---|---|---|
应用构成 | 由多个完整页面构成 | 一个外壳页面和多个页面片段构成 |
跳转方式 | 页面之间的跳转是从一个页面到另一个页面 | 一个页面片段删除或隐藏,加载另一个页面片段并显示。片段间的模拟跳转,没有开壳页面 |
跳转后公共资源是否重新加载 | 是 | 否 |
URL模式 | http://xxx/page1.html 和http://xxx/page2.html |
http://xxx/shell.html#page1 和http://xxx/shell.html#page2 |
用户体验 | 页面间切换加载慢,不流畅,用户体验差,尤其在移动端 | 页面片段间切换快,用户体验好,包括移动设备 |
能否实现转场动画 | 否 | 容易实现(手机APP动效) |
页面间传递数据 | 依赖URL 、cookie 或者localstorage ,实现麻烦 |
页面传递数据容易(Vuex 或Vue 中的父子组件通讯props 对象) |
搜索引擎优化(SEO) | 可以直接做 | 需要单独方案(SSR) |
特别适用的范围 | 需要对搜索引擎友好的网站 | 对体验要求高,特别是移动应用 |
开发难度 | 较低,框架选择容易 | 较高,需要专门的框架来降低这种模式的开发难度 |
- 本文作者: Raphael_Li
- 本文链接: https://lifei-2019.github.io/interview2/
- 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!