在React中,组件和组件之间通过props
传递数据的规范,极大地增强了组件之间的耦合性,而context
类似全局变量一样,里面的数据能被随意接触就能被随意修改,每个组件都能够改context
里面的内容会导致程序的运行不可预料。
Redux是一个独立专门用于做状态管理的JS库,帮助开发出行为稳定可预测的、易于测试的应用程序,通过react-redux,可集中式管理React应用中多个组件共享的状态。
本系列的两篇文章带你手写一个Redux,以及结合React实现自己的react-redux。
1 如何修改状态?
说到如何管理React应用的状态数据,最直接的想法应该是设置一个全局的状态对象(state),各个组件可以读取、修改该对象中状态数据,下面我们通过一个具体的例子演示一下,使用create-react-app
新建一个项目my-redux
,修改public/index.html
中的body
:
<body><div id='title'></div><div id='content'></div>
</body>
清除src/index.js
里面所有的代码,添加下面代码,改代码中包含了我们应用的状态,以及对状态的操作方法:
const appState = {title: {text: 'my-redux',color: 'red',},content: {text: '如何实现自己的redux?',color: 'blue'}
}
function renderApp (appState) {renderTitle(appState.title)renderContent(appState.content)
}
function renderTitle (title) {const titleDOM = document.getElementById('title')titleDOM.innerHTML = title.texttitleDOM.style.color = title.color
}
function renderContent (content) {const contentDOM = document.getElementById('content')contentDOM.innerHTML = content.textcontentDOM.style.color = content.color
}
renderApp(appState)
浏览器打开http://localhost:3000,显示如下页面:
以上代码通过将页面属性与状态变量绑定,实现了状态的统一管理,但是这种方案存在一个重大的隐患,渲染数据的时候,使用的是一个共享状态appState
,该变量没有任何封装保护,每个人都可以修改它:
// 这个方法中修改了 appState 的内容,比如:appState.title = null
// 出现问题的时候 debug 起来就非常困难
doSomthingMore()
// ...
renderApp(appState)
当renderApp(appState)
之前执行了一大堆函数操作,你根本不知道它们会对appState
做什么事情,renderApp(appState)
的结果无法得到保障。一旦共享数据可以任意修改,所有对共享状态的操作都是不可预料的,出现问题的时候debug起来就非常困难,这就是老生常谈的尽量避免全局变量。
组件之间需要共享数据 和 数据可能被任意修改导致不可预料的结果之间存在着矛盾。
解决方案: 提高数据修改的门槛,组件之间可以共享数据、修改数据。但是这个数据并不能直接改,只能执行某些允许的修改,而且你修改过程必须大张旗鼓,不能悄悄的改。
这里我们定义一个方法叫dispatch
,通过这个方法专门负责数据的修改:
function dispatch (action) {switch (action.type) {case 'UPDATE_TITLE_TEXT':appState.title.text = action.textbreakcase 'UPDATE_TITLE_COLOR':appState.title.color = action.colorbreakdefault:break}
}
所有对数据的操作必须通过dispatch
函数,它接受一个参数action
对象,里面必须包含一个type
字段来声明你到底想干什么。dispatch
在swtich
里面会识别这个type
字段,能够识别出来的操作才会执行对appState
的修改,这样就能管理所有对状态数据的操作。
比如上面的dispatch
它只能识别两种操作,
1.UPDATE_TITLE_TEXT :用action
的text
字段去更新appState.title.text
2.UPDATE_TITLE_COLOR:用action
的color
字段去更新appState.title.color
可以看到,action
里面除了type
是必须的以外,其他字段都是可以自定义的,任何的模块如果想要修改 appState.title.text,必须 大张旗鼓 地调用dispatch
:
dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本
dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改标题颜色
这样做有什么好处?
// 里面可能通过 dispatch 修改标题颜色
doSomthingMore()
// ...
renderApp(appState)
我们不需要担心renderApp(appState)
之前的那堆方法会对appState
做什么奇怪的操作,因为我们规定不能直接修改appState
,它们对appState
的修改必须只能通过dispatch
。而dispatch
的实现只能修改 title.text 和 title.color,通过引入dispatch
方法,组件间共享数据的方式发生了如下变化:
2 抽像 store 监听数据变化
现在,我们有了appState
和dispatch
,把它们集中到一个地方,给这个地方起个名字叫做store
,然后构建一个函数createStore
,用来专门生产这种state
和dispatch
的集合:
function createStore (state, stateChanger) {const getState = () => stateconst dispatch = (action) => stateChanger(state, action)return { getState, dispatch }
}
其中各个参数的含义与作用:
- state: 表示应用程序状态数据;
- stateChanger: 描述应用程序状态会根据
action
发生什么变化,相当于上文 dispatch 代码里面的内容; - getState: 用于获取
state
数据,把state
对象返回; - dispatch: 用于修改数据,接收
action
,把state
和action
一并传给stateChanger
。
通过createStore
,我们可以这样渲染页面:
const appState = {title: {text: 'my-redux',color: 'red',},content: {text: '如何实现自己的redux?',color: 'blue'}
}
function stateChanger (state, action) {switch (action.type) {case 'UPDATE_TITLE_TEXT':state.title.text = action.textbreakcase 'UPDATE_TITLE_COLOR':state.title.color = action.colorbreakdefault:break}
}
// renderApp()、createStore()方法,此处省略,参照上文
// ...
// 创建store,包含获取状态的getState,和操作状态的dispatch
const store = createStore(appState, stateChanger)
renderApp(appState) // 首次渲染
store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本
store.dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改标题颜色
renderApp(appState) // dispatch 修改完状态 appState 后,需要手动调用renderApp()再次触发渲染
上面的代码有一个问题,通过dispatch
修改数据的时候只是数据发生了变化,如果不手动调用renderApp()
,页面是不会发生变化的,我们希望数据变化的时候程序能够自动触发重新渲染。
这个问题可以通过观察者模式“监听”数据变化,然后重新渲染页面:
function createStore (state, stateChanger) {const listeners = []// 调用subscribe,可以将监听器传入内部的listeners数组,监听数据变化const subscribe = (listener) => listeners.push(listener)const getState = () => stateconst dispatch = (action) => {stateChanger(state, action)// 每当 dispatch 的时候,遍历并且执行监听器// 即当数据变化时候进行一些方法回调,比如说重现渲染listeners.forEach((listener) => listener())}return { getState, dispatch, subscribe }
}
createStore
里面定义了一个数组listeners
和一个新的方法subscribe
,可以通过 store.subscribe(listener)
的方式给subscribe
传入一个监听方法,这个函数会被push到listeners
数组当中;修改dispatch
,每次当它被调用的时候,除了会调用stateChanger
进行数据的修改,还会遍历listeners
数组里面的方法,然后一个个地去调用;监听回调方法可以通过subscribe
注册进listeners
数组。
完成以上修改后,在store
中注册renderApp()
回调方法,当数据变化时重新渲染,便会自动调用该方法:
// 其他方法及变量参考上文
// ...
const store = createStore(appState, stateChanger)
// 注册重新渲染回调方法,即当数据变化时候进行重现渲染
store.subscribe(() => renderApp(store.getState()))
renderApp(appState) // 首次渲染
store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本
store.dispatch({ type: 'UPDATE_CONTENT_COLOR', color: 'blue' }) // 修改标题颜色
// renderApp(appState) // 不再需要手动触发重新渲染了
3 纯函数的概念
在开始下面的内容前,先看下函数式编程里面非常重要的概念 —— 纯函数(Pure Function)。 纯函数本身是一个函数,同时满足以下两点:
1.函数的返回结果只依赖于它的参数,相同的输入,总是会的到相同的输出;
2.执行过程中没有任何副作用。
下面说下这两点:
3.1 函数的返回结果只依赖于它的参数
let a = 1;
function xAdd(x) {return x + a;
};
xAdd(1); //2
上面这个函数就不是一个纯函数,因为在程序执行的过程中,变量a发生改变,执行xAdd(1)时得到的输出不同。
function sum(x, y) {return x + y;
};
sum(1,2); //3
这个例子中,符合相同的输入得到相同的输出这个概念,sum是一个纯函数。
3.2 执行过程中没有任何副作用
到底什么是副作用?这里的副作用指的是函数在执行过程中产生了 外部可观察变化,比如:
- 发起HTTP请求
- 操作DOM
- 修改外部数据
let a = 1;
function func() {a = 'b';
};
func();
console.log(a); // b
我们运行了func函数,外部的变量a的值发生了改变,这就是产生了所谓的副作用,所以func不是一个纯函数。
function func2() {let a = 1;a = 'a';return a
};
func(); // a
函数fun2不会对产生外部可观察变化,也就不会产生副作用,它就是一个纯函数。
3.3 纯函数的好处
1.更容易进行测试,结果只依赖输入,测试时可以确保输出稳定;
2.更容易维护和重构,我们可以写出质量更高的代码;
3.更容易调用,我们不用担心函数会有什么副作用;
4.结果可以缓存,因为相同的输入总是会得到相同的输出。
3.4 纯函数运用的经典案例
1.数组的很多基本方法都是纯函数,例如map,forEach,filter,reduce等等;
2.redux中三大原则之一使用纯函数来执行修改,其中就运用了reducer来描述 action 如何改变 state tree;
3.Lodash 是一个一致性、模块化、高性能的 JavaScript 实用工具库,纯函数代表。
4 共享结构对象解决性能问题
4.1 整体刷新的性能问题
第二节中实现的createStore
仍然有比较严重的性能问题,我们通过subscribe
注册了重新渲染的回调函数,
store.subscribe(() => renderApp(store.getState())) // 监听数据变化
可通过dispatch
修改状态中的Title
文本数据,从而触发渲染,
store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本
但是,renderApp
的实现,同时渲染了Title
组件和Content
组件,Content
组件的状态数据没有发生变化,但还是触发了一次渲染:
function renderApp (appState) {renderTitle(appState.title)renderContent(appState.content)
}
这里提出的解决方案是,在每个渲染函数执行渲染操作之前先做个判断,判断传入的 新数据 和 旧数据 是不是相同,相同的话就不渲染了。 修改renderApp:
function renderApp(newAppState, oldAppState = {}) { // 防止 oldAppState 没有传入,所以加了默认参数 oldAppState = {}if (newAppState === oldAppState) return // 数据没有变化就不渲染了console.log('render app...')renderTitle(newAppState.title, oldAppState.title)renderContent(newAppState.content, oldAppState.content)
}
function renderTitle (newTitle, oldTitle = {}) {if (newTitle === oldTitle) return // 数据没有变化就不渲染了console.log('render title...')const titleDOM = document.getElementById('title')titleDOM.innerHTML = newTitle.texttitleDOM.style.color = newTitle.color
}
function renderContent (newContent, oldContent = {}) {if (newContent === oldContent) return // 数据没有变化就不渲染了console.log('render content...')const contentDOM = document.getElementById('content')contentDOM.innerHTML = newContent.textcontentDOM.style.color = newContent.color
}
使用新的renderApp()
:
const store = createStore(appState, stateChanger)
let oldState = store.getState() // 缓存旧的 state
store.subscribe(() => {const newState = store.getState() // 数据可能变化,获取新的 staterenderApp(newState, oldState) // 把新旧的 state 传进去渲染oldState = newState // 渲染完以后,新的 newState 变成了旧的 oldState,等待下一次数据变化重新渲染
})
刷新页面,查看日志输出,发现以下两个dispatch并没有触发页面重新渲染:
store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本
store.dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改标题颜色
原因是这样的,if条件中判断newAppState === oldAppState
比较的是对象引用,而用于比较的对象的引用始终没有发生变化(类似的问题还有深拷贝和浅拷贝的比较)。分析renderApp代码发现:
- renderApp比较的是:newAppState === oldAppState
- renderTitle比较的是:newAppState.title === oldAppState.title
- renderContent比较的是:newAppState.content === oldAppState.content
分析stateChanger
代码发现,仅仅修改了以下两处:
1.state.title.text = action.text
2.state.title.color = action.color
因此,上述三处if判断恒等于true,直接return,没有进行后面的渲染。
4.2 共享结构的对象
通过上一节的分析,我们发现重新渲染回调没有生效的原因是,我们 直接修改了状态对象中的属性,未修改对象自身, 导致新老状态对象比较时,对象引用是一样的,是同一个对象,renderXXX
函数认为没有发生变化,不需要刷新。
解决方法是,禁止直接修改原来的对象,一旦你要修改某些东西,你就得把修改路径上的所有对象复制一遍,生成一个新的对象,例如,我们不写下面的修改代码:
appState.title.text = 'spring-boot'
而是新建一个appState
,新建appState.title
,新建appState.title.text
:
let newAppState = { // 新建一个 newAppState...appState, // 复制 appState 里面的内容title: { // 用一个新的对象覆盖原来的 title 属性...appState.title, // 复制原来 title 对象里面的内容text: 'spring-boot' // 覆盖 text 属性}
}
return newAppState
appState
和newAppState
引用不一样,需要刷新;appState.conetnt
和newAppState.conetnt
引用一样,不需要刷新;appState.title
和newAppState.title
引用不一样,需要刷新。
按照上面的思路,改造stateChanger
:
function stateChanger (state, action) {switch (action.type) {case 'UPDATE_TITLE_TEXT':return { // 构建新的对象并且返回...state,title: {...state.title,text: action.text}}case 'UPDATE_TITLE_COLOR':return { // 构建新的对象并且返回...state,title: {...state.title,color: action.color}}default:return state // 没有修改,返回原来的对象}
}
因为stateChanger
不会修改原来对象了,而是返回对象,所以我们需要修改一下createStore
,state = stateChanger(state, action)
覆盖原来的state
:
function createStore (state, stateChanger) {const listeners = []// 调用subscribe,可以将监听器传入内部的listeners数组,监听数据变化const subscribe = (listener) => listeners.push(listener)const getState = () => stateconst dispatch = (action) => {state = stateChanger(state, action)// 覆盖原对象// 每当 dispatch 的时候,遍历并且执行监听器// 即当数据变化时候进行一些方法回调,比如说重现渲染listeners.forEach((listener) => listener())}return { getState, dispatch, subscribe }
}
刷新页面,查看日志,我们成功地把不必要的页面渲染优化掉了:
5 reducer的概念
经过了前面几节的内容,我们有了很通用的createStore
、stateChanger
,可以优化一下,将appState
和stateChanger
合并到一起,删除appState
对象:
function stateChanger (state, action) {if (!state) {return {title: {text: 'my-redux',color: 'red',},content: {text: '如何实现自己的redux?',color: 'blue'}}}switch (action.type) {case 'UPDATE_TITLE_TEXT':return {...state,title: {...state.title,text: action.text}}case 'UPDATE_TITLE_COLOR':return {...state,title: {...state.title,color: action.color}}default:return state}
}
stateChanger
现在既充当了获取初始化数据的功能,也充当了生成更新数据的功能,如果有传入state
就生成更新数据,否则就是初始化数据。
更新createStore
方法:
function createStore (stateChanger) {let state = nullconst listeners = []// 调用subscribe,可以将监听器传入内部的listeners数组,监听数据变化const subscribe = (listener) => listeners.push(listener)const getState = () => stateconst dispatch = (action) => {state = stateChanger(state, action)// 覆盖原对象// 每当 dispatch 的时候,遍历并且执行监听器// 即当数据变化时候进行一些方法回调,比如说重现渲染listeners.forEach((listener) => listener())}dispatch({}) // 初始化 statereturn { getState, dispatch, subscribe }
}
createStore
内部的state
不再通过参数传入,而是一个局部变量let state = null
,createStore
的最后会手动调用一次dispatch({})
,dispatch
内部会调用stateChanger
,这时候的state
是 null,所以这次的dispatch
其实就是初始化数据了。createStore
内部第一次的dispatch
导致state
初始化完成,后续外部的dispatch
就是修改数据的行为了。修改store
的获取方式:
const store = createStore(stateChanger)
我们给stateChanger
这个方法起一个更加形象的名字:reducer
,为什么叫reducer呢?因为这个函数实现了以下功能:
state + action = newState
是不是和MapReduce
里的reduce
是一个意思哈?而且这个reducer
还是个纯函数。
修改下参数名:
function createStore (reducer) {let state = nullconst listeners = []// 调用subscribe,可以将监听器传入内部的listeners数组,监听数据变化const subscribe = (listener) => listeners.push(listener)const getState = () => stateconst dispatch = (action) => {state = reducer(state, action)// 覆盖原对象// 每当 dispatch 的时候,遍历并且执行监听器// 即当数据变化时候进行一些方法回调,比如说重现渲染listeners.forEach((listener) => listener())}dispatch({}) // 初始化 statereturn { getState, dispatch, subscribe }
}
至此,我们给reducer下个定义,并明确其作用:
定义: createStore
接受一个叫reducer
的函数作为参数,这个函数规定是一个纯函数,它接受两个参数,一个是state
,一个是action
。
作用: 如果没有传入state
或者state
是 null,那么它就会返回一个初始化的数据。如果有传入 state
的话,就会根据action
来修改数据,但其实它并没有修改原始state
对象,而是要通过上节所说的把修改路径的对象都复制一遍,然后产生一个新的对象返回。如果它不能识别你的action
,它就不会产生新的数据,而是把state
原封不动地返回(default分支)。 reducer
是不允许有副作用的。你不能在里面操作 DOM,也不能发 Ajax 请求,更不能直接修改state,
它要做的仅仅是初始化和计算新的state
。 现在我们可以用这个createStore
来构建不同的store
了,只要给它传入符合上述的定义的reducer
即可:
function themeReducer (state, action) {if (!state) return {themeName: 'Red Theme',themeColor: 'red'}switch (action.type) {case 'UPATE_THEME_NAME':return { ...state, themeName: action.themeName }case 'UPATE_THEME_COLOR':return { ...state, themeColor: action.themeColor }default:return state}
}
const store = createStore(themeReducer)
6 总结
在上面的几节内容中,
1.我们从一个简单的例子开始,提出一个可以被不同模块任意修改共享的数据状态,其操作都是不可预料的,出现问题的时候debug起来就非常困难,需要避免全局变量;
2.于是我们提高了修改数据的门槛:必须通过dispatch
执行某些允许的修改操作;
3.我们抽取了一个createStore
方法,其返回的store
对象中包含了获取状态的方法getState
和修改状态的方法dispatch
;然后,通过观察者模式设置监听回调函数,在状态翻身变化时指定触发页面渲染;
4.针对渲染过程中,未发生状态的组件无效渲染的性能问题,通过引入“共享结构的对象”的概念,优化了其中无效的渲染;
5.引入rudex
中的reducer
,作为纯函数,负责初始化state
、根据state
和action
计算具有共享结构的新state对象。
6.1 完整代码
public/index.html
<!DOCTYPE html>
<html lang="en"><head><title>React App</title></head><body><div id='title'></div><div id='content'></div></body>
</html>
src/index.js
const appState = {title: {text: 'my-redux',color: 'red',},content: {text: '如何实现自己的redux?',color: 'blue'}
}
function stateChanger (state, action) {if (!state) {return {title: {text: 'my-redux',color: 'red',},content: {text: '如何实现自己的redux?',color: 'blue'}}}switch (action.type) {case 'UPDATE_TITLE_TEXT':return {...state,title: {...state.title,text: action.text}}case 'UPDATE_TITLE_COLOR':return {...state,title: {...state.title,color: action.color}}default:return state}
}
function renderApp(newAppState, oldAppState = {}) { // 防止 oldAppState 没有传入,所以加了默认参数 oldAppState = {}if (newAppState === oldAppState) return // 数据没有变化就不渲染了console.log('render app...')renderTitle(newAppState.title, oldAppState.title)renderContent(newAppState.content, oldAppState.content)
}
function renderTitle (newTitle, oldTitle = {}) {if (newTitle === oldTitle) return // 数据没有变化就不渲染了console.log('render title...')const titleDOM = document.getElementById('title')titleDOM.innerHTML = newTitle.texttitleDOM.style.color = newTitle.color
}
function renderContent (newContent, oldContent = {}) {if (newContent === oldContent) return // 数据没有变化就不渲染了console.log('render content...')const contentDOM = document.getElementById('content')contentDOM.innerHTML = newContent.textcontentDOM.style.color = newContent.color
}
function createStore (reducer) {let state = nullconst listeners = []// 调用subscribe,可以将监听器传入内部的listeners数组,监听数据变化const subscribe = (listener) => listeners.push(listener)const getState = () => stateconst dispatch = (action) => {state = reducer(state, action)// 覆盖原对象// 每当 dispatch 的时候,遍历并且执行监听器// 即当数据变化时候进行一些方法回调,比如说重现渲染listeners.forEach((listener) => listener())}dispatch({}) // 初始化 statereturn { getState, dispatch, subscribe }
}
const store = createStore( stateChanger)
let oldState = store.getState() // 缓存旧的 state
store.subscribe(() => {const newState = store.getState() // 数据可能变化,获取新的 staterenderApp(newState, oldState) // 把新旧的 state 传进去渲染oldState = newState // 渲染完以后,新的 newState 变成了旧的 oldState,等待下一次数据变化重新渲染
})
renderApp(appState) // 首次渲染
store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'spring-boot' }) // 修改标题文本
store.dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改标题颜色
// renderApp(appState) // dispatch 不需要再次出触发渲染
最后
整理了75个JS高频面试题,并给出了答案和解析,基本上可以保证你能应付面试官关于JS的提问。
有需要的小伙伴,可以点击下方卡片领取,无偿分享