本篇不讨论语法,我们讨论偏思想的东西。
首先,面向对象并不是说你写一个 class 就是面向对象了。在 Java 里面 Everything is class,全部都是 Class,还有 React 也需要写 class,所以很多人写 class 并不是他自己要写 class,而是编程语言或者框架要求他写 class。因此就会存在一个窘境,如下图所示:
虽然是写的 class,但是代码风格是面向结构的,只是套了一个 class 的外衣,真正面向对象的是所使用的框架。
所以 面向对象应该是一种思想,而不是你代码的组织形式 ,甚至有时候你连一个 class 都没写。
面向对象的英文为 Object Oriented,它的准确翻译应该为 "面向物件",而不是 "面向对象",只不过不知道是谁翻译了这么一个看似 "高大上" 但是不符合实际的名词。面向对象是对世界物件的抽象和封装,例如车子、房子和狗等。
面向对象有三个主要的特点:封装、继承和多态。
现在我要研究下狗,并且关注它的叫和咬人行为,所以我封装了一个狗的类,如下代码所示:
上面代码封装两个行为:叫、咬人,和一个属性:年龄。
现在我又要研究下哈士奇,如下图所示:
哈士奇是狗的一种,我让它继承了 Dog 这个类,于是它就继承了父类的行为,如它可以咬你:
然后哈士奇它有自己的行为,例如它时不时就露出奇怪的表情。
哈士奇也会叫,但是它不是 "汪汪汪" 地叫,它有时候会发出像狼嚎的声音,所以同样是叫的行为,但是哈士奇有自己特点,这个就是多态,如下所示:
当调用 Husky 的 bark 函数时就是 wolf wolf 而不是 wang wang 了:
如下图所示,一个页面会有多个上传图片的地方,每个上传地方都会生成一个进度条,如下图所示:
所以考虑把进度条封装成一个类 ProgressBar,如下代码所示:
- class ProgressBar{
- constructor($container){
- this.fullWidth = $container.width();
- this.$bar = null;
- }
- //设置进度
- setProgress(percentage){
- this.$bar.animate({width: this.fullWidth * percentage + "px"});
- }
- //完成
- finished(){
- this.$bar.hide();
- }
- //失败
- failed(){
- this.addFailedText();
- }
- addFailedText(){
- }
- }
ProgressBar 封装了设置进度、完成、失败的函数,这个就是面向对象的封装。
最后的 addFailedText 函数是内部的实现,不希望实例直接调,也就是说它是一个私有的、对外不可见的函数。但是由于 JS 没有私有属性、私有函数的概念,所以还是可以调的,如果要实现私有属性可能得通过闭包之类的技巧实现。
接着我想做一个带有百分比数字的进度条,如下图所示:
于是我想到了面向对象的继承,写一个 ProgressBarWithNumber 的类,继承 ProgressBar,如下代码所示:
- class ProgressBarWithNumber extends ProgressBar{
- constructor($container){
- super($container);
- }
- //多态
- setProgress(percentage){
- //先借助继承的父类的函数
- super.setProgress(percentage);
- this.showPercentageText(percentage);
- }
- showPercentageText(percentage){
- }
- }
子类继承了父类的函数,同时覆盖 / 实现了父类的某些行为。上面的 setProgress 函数既体现了多态又体现了继承。
再举一个例子,HTML 元素的继承关系。
如下图所示:
P 标签是用一个 HTMParaphElement 的类表示,这个类继承关系往上有好几层,最上层是 Node 类,Node 又组合 TreeScope,TreeScope 标明当前 Node 结点是属于哪个 document 的(一个页面可能嵌入 iframe)。
继承是为了实现复用,组合其实也是为了实现复用。继承是 is-a 的关系,而组合是 has-a 的关系。可以把上面的 ProgressBar 改成组合的方式,如下代码所示:
- class ProgressBarWithNumber{
- constructor($container){
- this.progressBar = new ProgressBar($container);
- }
- setProgress(percentage){
- this.progressBar.setProgress(percentage);
- this.showPercentageText(percentage);
- }
- showPercentageText(percentage){
- }
- }
在构造函数里面组合了一个 progressBar 的实例,然后在 setProgress 函数里面利用这个实例去设置进度条的百分比。
也就是说带有数字的进度条里面有一条普通的进度条,这是组合,而当我们用继承的时候就变成了带数字的进度条是一种进度条。这两个都说得通,但是上面 HTML 元素的例子里面,可以说一个 Node 结点有一个 TreeScope,但是不能说 Node 结点是一个 TreeScope.
那么是继承好用一点,还是组合好用一点呢?
在《Effective Java》里面有一个条款:
Item 16 : Favor composition over inheritance
意思为 偏向于使用组合而非继承 ,为什么说组合比较好呢?因为继承的耦合性要大于组合,组合更加灵活。继承是编译阶段就决定了关系,而组合是运行阶段才决定关系。组合可以组合多个,而如果要搞多重继承系统的复杂性无疑会大大增加。
就上面的进度条的例子来说,使用组合会比使用继承的方式好吗?假设某一天,带数字的进度条不想复用普通的进度条了,要复用另外一种类型的进度条,使用继承就得改它的继承关系,万一带数字的进度条还派生了另外一个类,这个孙子类如果刚好用了普通进度条的一个函数,那这个条链就断了,导致孙子类也要改。所以可以看出组合的方式更加简易,继承相对比较复杂。
但是如果要我在这之上加一个条款的话我会这么加:
Item -1: Favor Simple Ways over OOP
因为能用简单的方式解决问题就应该用简单的方式,而不是一着手就是各种面向对象的继承、多态的思想,带数字的 LoadingBar 其实不需要使用继承或者组合,只要带一个参数控制就好了,是否要显示数字。笔者认为应该先使用简洁的方式解决问题,然后再考虑性能、代码组织优化等。为了 5% 的效果,增加了系统 50% 的复杂度,其实不值得,除非那个问题是瓶颈问题,能够提升一点是一点。
接着重点说一下设计模式和 OOP 的编程原则。
单例是一种比较简单也是比较常见的模式。例如现在要定义 Task 类,要实现它的单例,因为全局只能有一个数组存放 Task,如果有任务就都放到这个队列里面,按先进先出的顺序执行。
于是我先写一个 Task 类:
- class Task{
- constructor(){
- this.tasks = [];
- }
- //初始化
- draw(){
- var that = this;
- window.requestAnimationFrame(function(){
- if(that.tasks.length){
- var task = that.tasks.shift();
- task();
- }
- })
- }
- addTask(task){
- this.tasks.push(task);
- }
- }
现在要实现它的单例,可以这么实现:
- var mapTask = {
- get: function(){
- if(!mapTask.aTask){
- mapTask.aTask = new Task();
- mapTask.aTask.draw();
- }
- return this.aTask;
- },
- add: function(task){
- mapTask.get().addTask(task);
- }
- };
每次 get 的时候先判断有 mapTask 有没有 Task 的实例了,如果没有则为第一次,先去实例化一个,并做些初始化工作,如果有则直接返回。然后执行 mapTask.get() 的时候就能够保证获取到的是一个单例。
但是这种实现其实不太安全,任何人可通过设置:
- mapTask.aTask = null;
去破坏你这个单例,那怎么办呢?一方面 JS 本身没有私有属性,另一方面要怎么解决留给读者去思考。
因为 JS 的 Object 本身就是单例的,所以可以把 Task 类改成一个 taskWorker,如下代码所示:
- var taskWorker = {
- tasks: [],
- draw(){
- },
- addTask(task){
- Task.tasks.push(task);
- }
- }
- var mapTask = {
- add: function(task){
- taskWorker.addTask(task);
- }
- };
显然第二种方式比较简单,但是它只能有一个全局的 task。而第一种办法可以拥有几种不同业务的 Task,不同业务互不影响。例如除了 mapTask 之外,还可以再写一个 searchTask 的业务。
这个例子已经提过很多次,这里再简单提一下。假设现在要弹几个注册的框,每个注册的框只是顶部的文案不一样,而其它地方包括逻辑等都一样,所以,我就把文案当作一个个的策略,使用的时候根据不同的类型,映射到不同的策略,如下图所示:
注册完成后需要去执行不同的操作,把这些操作也封装成一个个的策略,同样地根据不同的类型映射到不同的策略,如下图所示:
这样比去写 if-else 或者 switch-case 的好处就在于,如果以后要增加或者删除某种类型的业务,只需要去增删一个 type 就可以了,而不用去改动 if-else 的逻辑。这个就叫做开闭原则——对修改是封闭的,而对扩展是开放的。
观察者模式也是经常和前端打交道的一种模式,事件监听就是一种观察者模式,如下实现一个观察者模式:
- class Input{
- constructor(inputDom){
- this.inputDom = inputDom;
- this.visitors = {
- "click": []
- };
- }
- //添加访问者
- on(eventType, visitor){
- this.visitors.push(visitor);
- }
- //收到消息,把消息分发给访问者
- trigger(type, event){
- if(this.visitors[type]){
- for(var i = 0; i < this.visitors[type]; i++){
- this.visitors[type]();
- }
- }
- }
- }
观察者向消息的接收者订阅消息,一旦接收者收到消息后就把消息下发给它的观察者们。在一个地图绘制搜索的应用里面,点击最后一个点关闭路径,触发搜索:
其实不用再去手动地调搜索的接口了,因为地图本身就监听了 drag_end 事件,在这个事件里面会去搜索,所以在绘制完成之后只要执行:
- map.trigger("drag_end")
就可以了,即给 drag_end 事件的观察者们下发消息。
(4)适配器模式
在一个响应式的页面里面,小屏和大屏显示的分页样式不一样,小屏要这样显示:
而大屏要这样显示:
它们初始化和更新状态的函数都不一样,如下所示:
- //小屏
- var pagination = new jqPagination({
- });
- pagination.showPage = function(curPage, totalPage){
- pagination.setPage(curPage, totalPage);
- }
- //小屏
- var pagination = new Pagination({
- });
- pagination.showPage = function(curPage, totalPage){
- pagination.showItem(curPage, totalPage);
如果我每次用的时候都得先判断一下不同的屏幕大小然后去调不同的函数就显得有点麻烦了,所以可以考虑用一个适配器,对外提供统一的接口,如下所示:
- var screen = $(window).width() < 800 ? "small" : "large";
- var paginationAdapter = {
- init: function(){
- this.pagination = screen === "small" ? new jqPagination():
- new Pagination();
- if(screen === "large"){
- this.pagination.showItem = this.pagination.setPage;
- }
- },
- showPage: function(curPage, totalPage){
- this.pagination.showItem(curPage, totalPage);
- }
- }
使用者只要调一下 paginationAdapter.showPage 就可以更新分页状态,它不需要去关心当前是大屏还是小屏,由适配器去处理这些细节。
工厂模式是把创建交给一个 "工厂",使用者无需要关心创建细节,如下代码所示:
- var taskCreator = {
- createTask: function(type){
- switch(type){
- case "map":
- return new MapTask();
- case "search":
- return new SearchTask();
- }
- }
- }
- var mapTask = taskCreator.createTask("map");
需要哪种业务类型的 Task 的时候就传一个业务类型或者说产品名字给一个工厂,工厂根据名字去生产相应的产品给我。而我不需要关心它是怎么创建的,要不要单例之类的。
在一个搜索逻辑里面,为了显示搜索结果需要执行以下这么多个操作:
- hideNoResult(); //先隐藏没有结果的显示
- removeOldResult(); //删除老的结果
- showNewResult(); //显示新的结果
- showPageItem(); //更新分页
- resizePhoto(); //结果图片大小重置
于是考虑用一个门面把它包起来,如下图所示:
可以把那么多个操作封装成一个模块,对外提供一个门面叫 showResult,使用者只要调一下这个 showResult 就可以了,它不需要知道究竟要怎么去显示结果。
现在要实现一个发 twitter 的消息框,要求是当字数为 0 或者超过 140 的时候,发推按钮不可点击,并且剩余字数会跟着变,如下图所示:
我想用一个 state 来保存当前的状态,然后当用户输入的时候,这个 state 的数据会跟着变,同时更新发推按钮的状态,如下代码所示:
- var tweetBox{
- init(){
- //初始化一个state
- this.state = {};
- tweetBox.bindEvent();
- }
- setState(key, value){
- this.state[key] = value;
- }
- changeSubmit(){
- //通过获取当前的state
- $("#submit")[0].disabled = tweetBox.state.text.length === 0 ||
- tweetBox.state.text.length > 140;
- }
- showLeftTextCount(){
- $("#text-count").text(140 - this.state.text.length);
- }
- bindEvent(){
- $(".tweet-textarea").on("input", function(){
- //改变当前的state
- tweetBox.setState({"text", this.value});
- tweetBox.changeSubmit();
- tweetBox.showLeftTextCount();
- });
- }
用一个 state 保存当前的状态,通过获取当前 state 进行下一步的操作。
可以把它改得更加智能一点,即在上面 setState 的时候,自动去更新 DOM,如下代码所示:
然后还可以再做得更智能,状态变的时候自动去比较当前状态所渲染的虚拟 DOM 和真实 DOM 的区别,自动去改变真实 DOM,如下代码示:
- var tweetBox{
- setState(key, value){
- this.state[key] = value;
- renderDom($(".tweet"));
- }
- renderDom($currentDom){
- diffAndChange($currentDom,
- renderVirtualDom(tweetBox.state));
- }
- }
- '<input type="submit" disabled={{this.state.text.length === 0 || this.state.text.length > 140}}>'
这个其实就是 React 的原型了,不同的状态有不同的表现行为,所以可以认为是一个状态模式,并且通过状态去驱动 DOM 的更改。
(8)代理模式
如下图所示:
使用 React 不直接操作 DOM,而是把数据给 State,然后委托给 State 和虚拟 DOM 去操作 DOM,所以它又是一个代理模式。
(9)状态模式的另一个例子
React 的那个例子并不是很典型,这里再举一个例子,如下代码所示,改变一个房源的状态:
- if(newState === "sold"){
- if(currentSate === "building" ||
- currentState === "sold"){
- return "error";
- } else if(currentSate === "ready"){
- currentSate = "sold";
- return "ok";
- }
- } else if(newState === "ready"){
- if(currentState === "building"){
- currentState = "toBeSold";
- return "ok";
- }
- }
改一个房源的状态之前先要判断一下当前的状态,如果当前状态不支持的话那么不允许修改,要是像上面那样写的话就得写好多个 if-else,我们可以用状态模式重构一下:
- var stateChange = {
- "ready": {
- "buidling": "error",
- "ready": "error",
- "sold": "ok"
- },
- "building": {
- "buidling": "error",
- "ready": "ok",
- "sold": "error"
- }
- };
- if(stateChange[currentState][newState] !== "error"){
- currentState = newState;
- }
- return stateChange[currentState][newState];
你会发现状态模式和策略模式是孪生兄弟,它们的形式相同,只是目的不同,一个是封装成策略,一个是封装成状态。这样的代码就比写很多个 if-else 强多了,特别是当状态切换关系比较复杂的时候。
要实现一个贷款的计算器,如下图所示:
点了计算的按钮之后,除了要计算结果,还要把结果发给后端做一个埋点,所以写了一个 calculateResult 的函数:
因为要把结果返回出来,所以这个函数有两个功能,一个是计算结果,第二个是改变 DOM,这样写感觉不太好。那怎么办呢?
我们把这个函数拆了,首先有一个 LoanCalculator 的类专门负责计算小数的结果,如下代码所示:
- //计算结果
- class LoanCalculator{
- constructor(form){
- this.form = form;
- }
- calResult(){
- var result = …;
- this.result = result;
- return result;
- }
- getResult(){
- if(!this.result) this.result = this.calResult();
- return this.result;
- }
- }
它还提供了一个 getResult 的函数,如果结果没算过那先算一下保存起来,如果已经算过了那就直接用算好的那个。
然后再写一个 NumberFormater,它负责把小数结果格式化成带逗号的形式:
- //格式化结果
- class NumberFormator{
- constructor(calculator){
- this.calculator = calculator;
- }
- calResult(){
- var result = this.calculator.calResult();
- this.result = result;
- return util.formatMoney(result);
- }
- }
它的构造函数里面传一个 calculator 给它,这个 calculator 可以是上面的 LoanCalculator,获取到它的计算结果然后格式化。
接着写一个 DOMRenderer 的类,它负责把结果显示出来:
- //显示结果
- class DOMRenderer{
- constructor(calculator){
- this.calculator = calculator;
- }
- calResult(){
- var result = this.calculator.calResult();
- $(".pi-result").text(result);
- }
- }
最后可以这么用:
- var loadCalculator = new LoanCalculator(form);
- var numberFormator = new NumberFormator(loadCalculator);
- var domRenderer = new DOMRenderer(numberFormator);
- domRenderer.calResult();
- util.ajax("/cal-loan", {result: loadCalculator.getResult()})
可以看到它就是一个装饰的过程,一层一层地装饰,如下图所示:
下一个装饰者调上一个的 calResult 函数,对它的结果进一步地装饰。如果这些装饰者的返回结果类型比较平行的时候,可以一层层地装饰下去。
使用装饰者模式,逻辑是清晰了,代码看起来高大上了,但是系统复杂性增加了,有时候能用简单的还是先用简单的方式实现。
总结一下本文提到的面向对象的编程原则:
最后,如果遇到一个问题你先查一下有哪个设计模式或者有哪个原则可以指导和解决这个问题,那你就被套路套住了。武功学到最后应该是忘掉所有的招数,做到心中无法,随心所欲,拈手就来。这才是最高境界。相反地,你会发现那种整天高喊各种原则、各种理论的人,其实很多时候他自己也没实践过,只是在空喊口号。
浏览量: 2
来源: http://www.tuicool.com/articles/QFbEvqm