现在我们来实现一个场景。
首先有一个普通的正方形div。
然后有一堆设置按钮,我们可以通过这些按钮来控制div的显示/隐藏,背景颜色,边框颜色,长宽等等属性。如果要来实现这个效果我们应该怎么做?
在实践中类似的场景非常多,例如手机的设置,控制中心,还有每个网页都有的个人中心里的设置等等。
当然,如果我们仅仅只是通过一个按钮来控制一个div的单一属性,那非常简单,但实践中的场景往往更加复杂,第一个难点我们可能会有更多的属性需要控制,也有更多的目标元素需要控制。第二个难点则是在我们构建代码之初,目标元素们可能存在于不同的模块中,我们如何通过单一的变量来控制不同的目标元素?
当我们的项目变得越来越复杂,需要管理的状态值也会变得越来越多,如果只是用我们初学时在当前作用域随便定义一个变量的方式来做,那么对于你而言,肯定是一场无法预料的灾难。我们可能需要更多的时间去调试,需要fix更多的bug,需要面对杂乱无章的代码,还需要克制我们烦躁的心态,以及无休止的加班。而当需求变动时,我们还不得不忍受自己都看不下去的代码,再次重复之前的痛苦,我相信没人愿意尝试这种经历。那么我们应该怎么办?
当然办法肯定是有的,目前非常流行的redux就能够解决这样的问题。当然,redux虽然能独立使用,但在实践中往往和react搭配使用,且学习成本并不算小,目前对于我们来说强行去掌握它有点跨步太大。因此这里我们的方案是,针对我们准备要实现的效果,自己动手实现一个简单的状态管理模块,这样一来可以降低学习成本,二来能够让我们对于状态管理有更加深刻的认知。那么跟着我的思路,一起来试试吧。
准备工作
首先找到之前创建的学习项目es6app。
然后删除src文件夹中除index.js的所有文件。并将index.js的内容清空。
现在项目已经被清空了。
最后通过yarn start
指令运行项目。
状态管理模块
在src目录中新建一个状态管理模块,命名为state.js
。
首先我们需要创建一个状态树。在整个项目中,状态树是唯一的,我们会把所有的状态名与状态值通过key-value的形式保存在状态树中。
const store = {}
当我们根据需求往状态树中保存状态时,那么状态树大概会变成如下的形式:
store = {
show: 0,
backgroundColor: '#cccccc',
width: '200',
height: '200'
// ... more
}
我们在学习闭包的实例中有提到过,每一个模块,都是一个单例模式,也是一个闭包,因此如果我们想要在其他模块中操作store,那么就需要对外提供对应的接口。
根据需求,可以先大概列出可能会用到的方法,如果之后需要补充再另行添加。可能会用到的方法大概包括:
- registerState : 往状态树中放入新的状态值,
- getStore: 获取到整个状态树,
- getState: 获取到某一个状态值,
- setState: 修改状态树中的某一个状态值
具体代码如下:
// 往store中添加一个状态值
export const registerState = (status, value) => {
if (store[status]) {
throw new Error('状态已经存在。')
return;
}
store[status] = value;
return value;
}
// 获取整个状态树
export const getStore = () => store
// 获取某个状态的值
export const getState = status => store[status]
// 设置某个状态的值
export const setState = (status, value) => {
store[status] = value;
dispatch(status, value);
return value;
}
为了简化学习,方法比较简单,没有过多的考虑异常情况与健全处理,请勿直接运用于实践,实践可在此基础根据需要扩展
当我们通过交互改变状态值时,其实期待的是界面UI能够发生相应的改变。UI的变动可能会比较简答,也可能会非常复杂,因此为了能够更好的维护UI改变,我们将每个UI变化用函数封装起来,并与对应的状态值对应。这样,当状态值改变的同时,调用一下对应的UI函数就能够实现界面的实时变动了。
因此,除了需要一个store来保存状态值之外,我们还需要一个events对象来保存UI函数。
const events = {}
那么状态值与UI函数的对应关系如下:
store = {
show: 0,
backgroundColor: '#cccccc',
width: '200',
height: '200'
// ... more
}
events = {
show: function() {},
backgroundColor: function() {},
width: function() {},
height: function() {}
// ... more
}
// 通过相同的状态命名,我们可以访问到对应的状态值与函数
同样的道理,我们也需要提供几个能够操作events的方法。
// UI方法可以理解为一个绑定过程,因此命名为bind,在有点地方也称为订阅
export const bind = (status, eventFn) => {
events[status] = eventFn;
}
// 移除绑定
export const remove = status => {
events[status] = null;
return status;
}
// 需要在状态值改变的时候触发UI的变化,因此我们在setState方法中调用了该方法
export const dispatch = (status, value) => {
if (!events[status]) {
throw new Error('未绑定任何事件!')
}
events[status](value);
return value;
}
OK,一个简单的状态管理模块就这样完成了,接下来我们来看看应该如何运用它。
完整代码
// src/state.js
const store = {}
const events = {}
// 往store中添加一个状态值,并确保传入一个初始值
export const registerState = (status, value) => {
if (store[status]) {
throw new Error('状态已经存在。')
return;
}
store[status] = value;
return value;
}
// 获取整个状态树
export const getStore = () => store
// 获取某个状态的值
export const getState = status => store[status]
// 设置某个状态的值
export const setState = (status, value) => {
store[status] = value;
dispatch(status, value);
return value;
}
// 将状态值与事件绑定在一起,通过status-events 的形式保存在events对象中
export const bind = (status, eventFn) => {
events[status] = eventFn;
}
// 移除绑定
export const remove = status => {
events[status] = null;
return status;
}
export const dispatch = (status, value) => {
if (!events[status]) {
throw new Error('未绑定任何事件!')
}
events[status](value);
return value;
}
注册状态值模块
我们需要管理很多的状态,我们可以在每一个使用到这些状态值的模块中各自注册,而我个人更加偏向于使用一个单独的模块来注册状态。如果你担心自己会忘记状态值的作用,建议每一个都做好注释。
注册状态的方式就是利用状态管理模块中定义registerState方法来完成即可。
// src/register.js
import { registerState } from './state';
// 控制显示隐藏
registerState('show', 0);
registerState('backgroundColor', '#FFF');
registerState('borderColor', '#000');
registerState('width', 100);
registerState('height', 100);
//... and more
功能函数模块
往往每一个项目中都会有这样一个功能函数模块,我们会把一些封装好的功能性的方法都在放这个模块中去。例如我们在实践中常常会遇到在一个数组中拿到最大的那个值,获取url中某个属性对应的具体值,对时间格式按需进行处理等等需求,我们就可以直接将这些操作封装好,存放于工具函数模块中,在使用时引入即可。
当然,这个例子中我们不会用到特别多的功能函数,因此这里就封装了一个示意一下,再下一个例子中我们再封装更多的功能函数。
// src/utils.js
// 获取DOM元素属性值
export const getStyle = (obj, key) => {
return obj.currentStyle ? obj.currentStyle[key] : document.defaultView.getComputedStyle( obj, false )[key];
}
除此之外,我们也可以引入lodash.js这样的工具库。lodash是一个具有一致接口、模块化、高性能的工具库,它封装了许多我们常用的工具函数,在实践开发中对我们的帮助非常大。
目标元素模块
目标元素,也就是可能会涉及到UI改变的元素。之前在创建状态管理模块时已经提到,我们需要将UI改变的动作封装为函数,并保存/绑定到events对象中。这个操作就选择在目标元素模块中来完成。
首先在public/index.html
中写入一个div元素。
<div class="target"></div>
然后在src/index.css
中写好对应的样式。
.target {
width: 100px;
height: 100px;
background: #CCC;
/* display: none; */
transition: 0.3s;
}
.target.hide {
display: none;
}
此处我们的目标元素是一个正方形的div元素,我们将会通过控制按钮来改变它的显示/隐藏,边框,背景,长宽等属性。因此该模块主要要做的事情,就是根据注册的状态变量,绑定UI变化的函数。具体代码如下:
// src/box.js
import { bind } from './state';
import { getStyle } from './utils';
import './register';
const div = document.querySelector('.target');
// control show or hide
bind('show', value => {
if (value === 1) {
div.classList.add('hide');
}
if (value === 0) {
div.classList.remove('hide');
}
})
// change background color
bind('backgroundColor', value => {
div.style.backgroundColor = value;
})
// change border color
bind('borderColor', value => {
const width = parseInt(getStyle(div, 'borderWidth'));
if (width === 0) {
div.style.border = '2px solid #ccc';
}
div.style.borderColor = value;
})
// change width
bind('width', value => {
div.style.width = `${value}px`;
})
bind('height', value => {
div.style.height = `${value}px`;
})
控制模块
我们可能会通过按钮,input框,或者滑块等不同的方式来改变状态值,因此控制模块将会是一个比较复杂的模块。 为了更好的组织代码,一个可读性和可维护性都很强的方式是将整个控制模块拆分为许多小模块,每一个小模块仅仅只完成一个状态值的控制操作。
因此我们需要根据需求,分别创建对应的控制模块。
首先在public/index.html
中添加相应的html代码。
<div class="control_wrap">
<div><button class="show">show/hide</button></div>
<div>
<input class="bgcolor_input" type="text" placeholder="input background color" />
<button class="bgcolor_btn">sure</button>
</div>
<div>
<input type="text" class="bdcolor_input" placeholder="input border color" />
<button class="bdcolor_btn">sure</button>
</div>
<div>
<span>width</span>
<button class="width_reduce">-5</button>
<button class="width_add">+5</button>
</div>
<div>
<span>height</span>
<button class="height_reduce">-</button>
<input type="text" class="height_input" readonly>
<button class="height_add">+</button>
</div>
</div>
现在我们在src目录下创建一个controlBtns文件夹,该文件夹中全部用来存放控制模块。然后依次编写控制模块的代码即可。
- 控制目标元素显示隐藏的模块。
// src/controlBtns/showBtn.js
import { getState, setState } from '../state';
const btn = document.querySelector('.show');
btn.addEventListener('click', () => {
if (getState('show') == 0) {
setState('show', 1);
} else {
setState('show', 0)
}
}, false);
- 控制目标元素背景颜色变化的模块。
// src/controlBtns/bgColor.js
import { setState } from '../state';
const input = document.querySelector('.bgcolor_input');
const btn = document.querySelector('.bgcolor_btn');
btn.addEventListener('click', () => {
if (input.value) {
setState('backgroundColor', input.value);
}
}, false);
- 控制目标元素边框颜色变化的模块。
// src/controlBtns/bdColor.js
import { setState } from '../state';
const input = document.querySelector('.bdcolor_input');
const btn = document.querySelector('.bdcolor_btn');
btn.addEventListener('click', () => {
if (input.value) {
setState('borderColor', input.value);
}
}, false);
- 控制目标元素宽度变化的模块。
// src/controlBtns/width.js
import { getState, setState } from '../state';
const red_btn = document.querySelector('.width_reduce');
const add_btn = document.querySelector('.width_add');
red_btn.addEventListener('click', () => {
const cur = getState('width');
if (cur > 50) {
setState('width', cur - 5);
}
}, false)
add_btn.addEventListener('click', () => {
const cur = getState('width');
if (cur < 400) {
setState('width', cur + 5);
}
}, false)
- 控制目标元素高度变化的模块。
// src/controlBtns/height.js
import { getState, setState } from '../state';
const red_btn = document.querySelector('.height_reduce');
const add_btn = document.querySelector('.height_add');
const height_input = document.querySelector('.height_input');
height_input.value = getState('height') || 100;
red_btn.addEventListener('click', () => {
const cur = getState('height');
if (cur > 50) {
setState('height', cur - 5);
height_input.value = cur - 5;
}
}, false)
add_btn.addEventListener('click', () => {
const cur = getState('height');
if (cur < 400) {
setState('height', cur + 5);
height_input.value = cur + 5;
}
}, false)
最后将这些模块拼合起来。
// src/controlBtns/index.js
import './showBtn';
import './bgColor';
import './bdColor';
import './width';
import './height';
在构建工具中,如果我们引入一个文件夹当做模块,那么相当于默认引入的是该文件下的名为index.js
的模块,因此我们可以通过在controlBtns文件夹下创建index.js的方式,来让该文件夹成为一个模块。
也就是说,在引入这个模块时:
import './controlBtns';
// 等价于
import './controlBtns/index';
细心的读者肯定已经发现了,我们给按钮绑定点击事件时,仅仅只是对状态值做了改变,而没有考虑对应的UI变化。这是为什么?
可能在大家以前的开发经验中,要改变一个元素的某个属性,一般来说会有状态值的变化,并且还有对应的UI操作。我们这里的做法好像有点不太一样。
其实我这里是利用这样的一个例子,带大家尝试一下分层的开发思维。这里例子中,我们将状态控制设定为控制层,而UI变化设定为view层。我们只需要在目标元素模块中,将view层的变化封装好,那么利用状态管理模块中的机制,在控制层,我们就只需要单一的考虑状态值的变化即可。
这样处理之后,我们开发重心,就从考虑整个界面的变化,转移到了仅仅只考虑状态值的变化。这样做的好处是极大的简化了我们在实现需求的过程中所需要考虑的问题。在未来的进阶学习中,大家可能还会大量接触到这样的开发思路。
最后拼合模块
在src目录下的index.js文件中,我们可以通过import
将需要的模块拼合起来。
// src/index.js
import './controlBtns';
import './box';
import './index.css';
OK,这个时候,我们需要的项目就已经全部完成了,如果你在跟着动手实践的话,相信现在你已经能够看到项目的最终效果。整个项目的相关目录结构如下:
+ public
- index.html
+ src
- index.js
- index.css
- box.js
- state.js
- utils.js
- register.js
+ controlBtns
- index.js
- showBtn.js
- bgColor.js
- bdColor.js
- width.js
- height.js
项目小结
模块化的开发思路,实际上是通过视觉元素,功能性等原则,将代码划分为一个一个拥有各自独立职能的模块。我们通过ES6的modules语法按需将这些模块组合起来,并借助构建工具打包成为我们熟知的js文件的这样一个过程。
当然在实践中我们可能会遇到更复杂的情况。例如目标元素并非单一元素的改变,而是整个区域发生变化,又例如控制目标元素变化的好几个状态值同时发生变化时带来的性能问题等等。当然大家并不用太过担心,这些问题都会是新的挑战,但是我相信大家在掌握了书中知识的情况下,花点时间去调试和折腾都是能够克服这些挑战的。这也是大家从初级往更高级进步的必经之路。
当然,大家也可以主动在此例子的基础上去增加复杂度。例如新增多个目标元素。让目标元素某个属性同时由几个状态值控制等。