Giter VIP home page Giter VIP logo

lucifier129.github.io's Introduction

Lucifier129.github.io

lucifier129.github.io's People

Contributors

lucifier129 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

lucifier129.github.io's Issues

haskell: function as functor, applicative, monad and monoid

在 haskell 里,function 本身也是 functor, applicative, monad 和 monoid。

理念

function 是天然的 container。

- b 是 test 函数包含的 value test a 函数调用是获取 container 内部 value 的方式
test :: a -> b

function as functor

将 fmap 应用于 function 这种 functor,fmap f function。相当于构造一个新函数 \a -> f (test a)

test a 把 function 里包含的 value 取出来,作为 f 的参数传入。然后 fmap 需要返回 functor,所以返回包裹的新函数 \a -> f (test a)

验证代码如下

- 输出 (2 + 1) * 2 = 6 
fmap (\x -> x * 2) (\x -> x + 1) $ 2

function as applicative

applicative 的函数 <*> 签名是 (<*>) :: Applicative f => f (a -> b) -> f a -> f b,两个 container 里,一个包含的 value 是 function 类型,另一个包含的是参数,<*> 把两个容器合并起来,将另一个 container 里的参数 value,传入 function 类型的 container 里。

function 自身是容器,其返回值是容器内的 value。那么容器里的 value 又是函数的情况就是高阶函数(返回函数的函数)。

验证代码如下

- 两个函数的第一个参数是同样的其返回值一个是函数一个是参数调用后 z * 3 的结果作为参数传入 y -> x + y 函数
- 相当于构造了函数 \x -> x + (x * 3)输出 12
(\x y -> x + y) <*> (\z -> z * 3) $ 3

function as monad

monad 的 >>= 函数签名是 (>>=) :: Monad m => m a -> (a -> m b) -> m b,第二个参数是一个用上一个容器里的 value 构造下一个容器的函数。

验证代码如下

- 第一个 monad 里的 value 是 x + 1作为参数传入了 `a -> m b`
- 因为 `a -> m b` 也要返回相同的 monad所以它是高阶函数上一个 monad 的 value 填充了其第一个参数第二个参数跟上一个 monad 的参数是相同的
- 相当于构造了函数 \x -> (x + 1) * x输出 6
(\x -> x + 1) >>= (\x y -> x * y) $ 2

function as monoid

function 可以满足 monoid 的两个构建 memptymappend,其中 memptyid 函数,任何函数跟 id 函数组合起来,等于其自身。mappend则是 compose,组合两个函数,通常用 . 函数来表示。

f.id = f
id.f = id
(f.g).h = f.(g.h) 

use do notation for function

既然 function 也是 monad,那么我们就可以使用 do notation 来写。

- 从 n1 是 \x -> x + 1 的返回值
- 我们用 const 构造一个函数 monad方便 <- 获取值
- 最后用 const 构造新的函数 monad结束这个过程
- test 2 将输入 (x + 1) + ((x + 1) *2) = 9
test = do
  n1 <- \x -> x + 1
  n2 <- const $ n1 * 2
  const $ n1 + n2

如何写一个你自己的jQuery库?

如何写一个你自己的jQuery库?

前言

本文面向的读者群如下:

  • 前端交互重度依赖jQuery库
  • 具备一定的原生js基础知识
  • 开始阅读某一版本的jQuery源码

本文采用的写作与编码手法如下:

  • 以标准实现为主,不考虑兼容性
  • 函数实现以新手直觉式为主,而非jQuery源码中经过千锤百炼的版本
  • 粗略搭建类jQuery式骨架,最终产物不以投入生产为目的,仅供参考
  • 用最终实现的库,实现一个在现代浏览器中正常工作的轮播图效果

注意:代码大致仿照jQuery,不代表跟它的内部实现以及api用法完全一样。

全部代码打包:猛击这里

轮播图实现DEMO:猛击这里

轮播图jsbin地址:猛击这里

立即执行的匿名函数

不管是模块化开发还是非模块化开发,建立一个工具库时,一般都得用到匿名函数。

差别在于:模块化开发依赖模块加载器提供的define函数,匿名函数作为参数传入,会自动getValue求值。而非模块化开发,则用特殊的格式写立即执行的匿名函数,其中一种格式如下:

;(function(window) {
    //这里写你所有的内容
}(window));

开头的分号,意在防止与其他js文件合并压缩时,由于上一个文件没有用分号结尾而产生问题。最末尾的分号则是防止与下一个js文件发生合并冲突。

将全局对象window作为参数传入,则可以使之在匿名函数内部作为局部变量访问,提供访问速度。

保存常用函数为局部变量

有一些数组或对象的方法经常能使用到,应将它们保存为局部变量以提高访问速度。

;(function(window){
    var obj = {},
                toStr = obj.toString,
                hasOwnProp = obj.hasOwnProperty,
                arr = [],
                slice = arr.slice,
                push = arr.push;

            var isObject = function(obj) {
                    return obj == null ? obj.toString() : toStr.call(obj) === '[object Object]';
                },
                isArray = Array.isArray || function(obj) {
                    return toStr.call(obj) === '[object Array]';
                },
                isFunction = function(obj) {
                    return typeof obj === 'function';
                },
                isString = function(obj) {
                    return typeof obj === 'string';
                },
                isBoolean = function(obj) {
                    return typeof obj === 'boolean';
                },
                inArray = function(arr, item) {
                    return arr.indexOf(item) !== -1;
                };

}(window));

定义一个迭代器each

本来嘛,不考虑兼容性,以标准实现为主,数组的迭代器可以用其原生的forEach,而对象的迭代则用for inhasOwnProperty。但是为了一致性,定义一个数组与对象通用的迭代器,还是很有用的。

更重要的是,这个each函数还可以设计为能迭代类数组(对象以0、1、2……为属性名并且具有length属性);

    function each(obj, callback) {
                //如果既不是对象也不是数组,不迭代,直接返回
                if (!isObject(obj) && !isArray(obj)) {
                    return obj;
                }
                var len = obj.length;
                //要求obj.length类型为数字且不小于0
                //符合该条件就当做数组来迭代
                if (typeof len === 'number' && len >= 0) {
                    //如果有用户滥用length属性,导致错误是其活该
                    for (var i = 0; i < len; i += 1) {
                        //callback函数的this值为数组中的每一项
                        if (callback.call(obj[i], i, obj[i]) === false) {
                            //如果callback函数的返回值等价于false
                            //终止迭代
                            break;
                        }
                    }
                } else {
                    //程序运行到这一步,说明该迭代的Obj类型为object
                    for (var prop in obj) {
                        if (hasOwnProp.call(obj, prop)) {
                            if (callback.call(obj[prop], prop, obj[prop]) === false) {
                                break;
                            }
                        }
                    }
                }
                //返回被迭代的数组或对象
                return obj;
            }
            //好用的函数,输出为静态方法
            jQuery.each = each;

定义一个对象扩展函数extend

我们不能每次都手动将一个对象的属性或方法拷贝到另一个对象中,所以要定义一个批量拷贝的函数extend。作为粗糙模拟,不考虑深度克隆。

    function extend(target) {
                //将除target之外的所有参数保存为sources
                var sources = slice.call(arguments, 1);
                //迭代器each派上了用场
                each(sources, function() {
                    each(this, function(key, value) {
                         target[key] = value;
                    });
                });
                //返回被扩展的对象
                return target;
            }

             //好用的函数,输出为静态方法
             jQuery.extend = extend;

定义一个压入栈函数pushStack

我们已有迭代器each,遍历数组与对象很方便;但是,数组的长度length是可以增减的,增加时一个个赋值进去太麻烦,我们需要一个批量添加项目的工具函数。

这个方法类似于数组的extend方法。

    function pushStack(target) {
                var len = target.length;

                if (typeof len !== 'number' || len < 0) {
                    //没有length属性,或者它小于0,直接返回target
                    return target;
                }

                //保存除target外的所有参数并转化为数组形式
                var sources = slice.call(arguments, 1);

                each(sources, function() {
                    //this值就是sources数组中的每一项
                    var len = this.length;

                    //同样判断是否为数组或类数组
                    if (typeof len !== 'number' || len < 0) {
                        return;
                    }

                    //之前保存的常用函数之一push,派上用场
                    //与apply结合,批量push进target中
                    push.apply(target, this);
                });
                return target;
            }

定义一个作为局部函数的jQuery

有些库的做法是一开始就定义一个全局变量,而另一些的做法是先定义成局部变量,最后再赋值给一个全局变量。这里采用的是后者。

;(function(window){
    //请将这里当做对上面那块常用函数的折叠部分,下同

    function jQuery(selector, context) {
        return new jQuery.init(selector, context);
    }
}(window));

目前都提倡采用无new操作符的做法,所以让jQuery.init来代理jQuery函数,由它来处理参数与返回值。

本文做法其实是zepto式的,jQuery源码里采取了更复杂的方法,此处为简便,没有沿用。jQuery.init函数内部实现如下

    //函数也是对象,也可以添加属性和方法
            jQuery.init = function(selector, context) {

                //定义length属性,成为类数组
                //可以用each迭代,用pushStack批量增加元素
                this.length = 0;

                if (selector && selector.nodeName) {
                    //如果第一个参数为dom节点
                    //保存为this[0]
                    this[0] = selector;
                    this.length = 1;
                } else if (isFunction(selector)) {
                    //如果第一个参数为函数,则为DOM ready 事件
                    return document.addEventListener('DOMContentLoaded', selector, false);
                } else {
                    //原生方法处理selector选择器,如果有context上下文,则以它为起点搜索,否则以document为始
                    //要求context必须是DOM,即便是jQuery的实例也不行,因为我们只是粗糙模拟
                    var items = (context && context.nodeName ? context : document).querySelectorAll(selector);

                    pushStack(this, items);
                }


            };

jQuery$函数的参数范围非常广,我们这里只取最常用的一种。

处理jQuery.init与jQuery的原型继承关系

这方面,懂的人觉得简单,不懂的人一头雾水,多说无益,看代码为上。

    //将jQuery的原型对象放到它的fn属性中
            jQuery.fn = jQuery.prototype;
            //本来这句与上句并做一起,为了便于理解,分拆开来
            //jQuery.init是函数,函数都有prototype原型对象
            //而且是可改变的,让它指向jQuery.prototype
            jQuery.init.prototype = jQuery.prototype;

            //设置一个jQuery的简写
            var $ = jQuery;

            //添加extend方法给jQuery的原型
            //专门用来拓展它自己
            $.fn.extend = function() {
                return extend.apply(window, [this].concat(slice.call(arguments)));
            };

给jQuery的原型添加方法

至此,基本骨架搭建好了,添加一些原型方法让这个库更加羽翼丰满吧。

        $.fn.extend({
            //each在这里很简单
            each: function(callback) {
                return each(this, callback);
            },
            //以下为搜索dom节点的操作
            find: function(selector) {
                //什么参数都不传,在这里返回空的jQ实例, 下同
                var ret = $();
                var nodes = [];
                //其实可以用each(this, callback)
                //但既然已经有$.fn.each,那就直接使用
                this.each(function() {
                    //注意:这里的this值,是上一个this值的项
                    //请看each函数的源码
                    var items = this.querySelectorAll(selector);

                    //搜索到的新元素先推入一个数组
                    //毕竟push为数组原生方法,比类数组更快
                    //items里有元素才推进去
                    items.length && push.apply(nodes, items);
                });
                //最后才推进新jQ实例
                return pushStack(ret, nodes);
            },
            eq: function(index) {
                index = index >= 0 ? index : index + this.length;
                return $(this[index]);
            },
            children: function(selector) {
                //返回新jQ实例
                var ret = $();
                var nodes = [];
                //如果有选择器参数,则新建一个jQuery专用的随机id
                var id = selector || 'jQuery' + Math.random().toString(36).substr(2);

                this.each(function() {
                    var items;
                    if (selector) {
                        //设置id,如果有就用其以前的id
                        this.id = this.id || id;
                        //构造选择器'#id>.target',选中所需元素
                        items = document.querySelectorAll(this.id + '>' + selector);
                        //拿到之后删除添加的随机id
                        if (this.id === id) {
                            this.removeAttribute('id');
                        }
                    } else {
                        //没选择器,直接获取children属性中的节点
                        items = this.children;
                    }

                    items.length && push.apply(nodes, items);
                });
                //推入栈
                return pushStack(ret, nodes);
            },
            first: function() {
                //新实例,下同
                var ret = $();
                var nodes = [];
                this.each(function() {
                    var item = this.firstElementChild;
                    //存在第一个非文本非注释的元素节点,才推进去
                    item && nodes.push(item);
                });
                //最后才全部压入栈,pushStack直接返回第一个参数,恰好是我们要返回的
                return pushStack(ret, nodes);
            },
            last: function() {
                var ret = $();
                var nodes = [];
                this.each(function() {
                    var item = this.lastElementChild;
                    item && nodes.push(item);
                });
                return pushStack(ret, nodes);
            },
            siblings: function(selector) {
                var ret = $();
                var nodes = [];
                //如果有选择器参数,则新建一个jQuery专用的随机id
                var id = selector || 'jQuery' + Math.random().toString(36).substr(2);
                this.each(function() {
                    //找到父节点
                    var parent = this.parentNode,
                        items;
                    if (selector) {
                        //设置id
                        parent.id = parent.id || id;
                        items = document.querySelectorAll(parent.id + '>' + selector);
                        //删除id
                        if (parent.id === id) {
                            parent.removeAttribute('id');
                        }
                        items.length && push.apply(nodes, items);
                    } else {
                        push.apply(nodes, parent.children);
                    }
                    //从数组中删除该节点,则余下全部兄弟节点
                    nodes.splice(nodes.indexOf(this), 1);
                });

                return pushStack(ret, nodes);
            },
            parent: function() {
                var ret = $();
                var nodes = [];

                this.each(function() {
                    var parent = this.parentNode;
                    parent && nodes.push(parent);
                });

                return pushStack(ret, nodes);
            },
            index: function() {
                var target = this[0];
                return slice.call(target.parentNode.children).indexOf(target);
            },
            append: function(node) {
                var len = this.length;
                this[0].appendChild(node);
                return this;
            },
            prepend: function(node) {
                var len = this.length;
                var first = this[0].firstElementChild;
                this[0].insertBefore(node, first);
                return this;
            }
        });

如上,添加了一些dom遍历的方法,下面添加css类的方法,分批书写,便于理解和阅读。

    $.fn.extend({
                css: function() {
                    //css方法既是getter又是setter,参数不定
                    //干脆不写形参,将实参转为数组形式
                    var args = slice.call(arguments);
                    var len = args.length;

                    //如果参数数量为1,且其为字符串
                    //就是 $(elem).css('color')
                    if (len === 1 && typeof args[0] === 'string') {
                        //get one
                        return getComputedStyle(this[0], null).getPropertyValue(args[0]);
                    } else if (len === 2) {
                        //形式为:$(elem).css('color', '#333');
                        //set all
                        return this.each(function() {
                            this.style[args[0]] = args[1];
                        });
                    }
                },
                addClass: function(classNames) {
                    //如果不是字符串参数,直接返回this
                    if (typeof classNames !== 'string') {
                        return this;
                    }
                    //去掉两端空白符后,返回数组形式
                    classNames = classNames.trim().split(' ');
                    return this.each(function() {
                        var classList = this.classList;
                        //再遍历一遍,添加所有class名
                        each(classNames, function(key, value) {
                            classList.add(value);
                        });
                    });
                },
                removeClass: function(classNames) {
                    //如果不是字符串参数,直接返回this
                    if (typeof classNames !== 'string') {
                        return this;
                    }
                    //去掉两端空白符后,返回数组形式
                    classNames = classNames.trim().split(' ');
                    return this.each(function() {
                        var classList = this.classList;
                        //再遍历一遍,删除所有class名
                        each(classNames, function(key, value) {
                            //与addClass只有一个差别
                            //许多可以合并函数来优化都没有做,只为直观
                            classList.remove(value);
                        });
                    });
                }
            });

要做轮播图不需要太多东西,这会儿轮到动画animate了

    //动画组件要定义一些工具方法
            var nextTick = requestAnimationFrame || function(fn) {
                return setTimeout(fn, 1000 / 60);
            };

            function getStyle(elem, prop) {
                return parseFloat(getComputedStyle(elem, null).getPropertyValue(prop), 10);
            }

            function animate(elem, propObj, duration, callback) {
                var start = +new Date();
                var oldValue = {};
                var diff = {};
                var ratio;
                for (var prop in propObj) {
                    diff[prop] = propObj[prop] - (oldValue[prop] = getStyle(elem, prop));
                }


                function move() {
                    ratio = (+new Date() - start) / duration;
                    if (ratio < 1) {
                        each(diff, function(prop) {
                            elem.style[prop] = oldValue[prop] + this * ratio + 'px';
                        });
                        nextTick(move);
                    } else {
                        each(diff, function(prop) {
                            elem.style[prop] = propObj[prop] + 'px';
                        });
                        callback();
                    }
                }

                move();
            }

        function noop() {};

        $.fn.animate = function(propObj, duration, callback) {
            var self = this,
                len = self.length,
                count = 0;
            return self.each(function() {
                animate(this, propObj, duration || 400, typeof callback === 'function' ? function() {
                    ++count === len && callback.call(self);
                } : noop);
            });
        };

由于轮播图中用到的事件函数比较简单,故这里不再添加事件模块,就用目前的产物,尝试写一个轮播图吧。

先将这个工具库输出到全局对象;

    window.jQuery = window.$ = jQuery;

深入到源码:解读 redux 的设计思路与用法

前言

redux 是 facebook 提出的 flux 架构的一种优秀实现;而且不局限于为 react 提供数据状态处理。它是零依赖的,可以配合其他任何框架或者类库一起使用。要想配合 react ,还得引入 react-redux

redux 团队的野心比较大,并不想让 redux 局限于 react 生态链中的一环。他们让 redux 自身保持简洁以便适配各种场景,让社区发展出各种 redux-* 中间件或者插件,从而形成它自己的生态系统。

redux 的核心很简洁。这篇文章将专注于解读 redux 核心的设计思路,以 Isomorphism-react-todomvc 这个项目为示例。它很可能是目前实现最完备的 react/redux todomvc demo,包含 react/redux 服务端渲染、路由filter、websocket同步等。

可以猛戳 DEMO 地址。在控制台里能看到 redux-logger 中间件输出的 action 日志,它们清晰地反映了业务逻辑是怎样的 。如果有其他人在编辑 todolist,基于 websocket 服务端推送技术的支持,你也可以直接看到别人的操作过程。

理念与设计

为什么要有 action ?

每个 web 应用都至少对应一个数据结构,而导致这个数据结构状态更新的来源很丰富;光是用户对 view 的操作(dom 事件)就有几十种,此外还有 ajax 获取数据、路由/hash状态变化的记录和跟踪等。

来源丰富不是最可怕的,更可怕的是每个来源提供的数据结构并不统一。DOM 事件还好,前端可以自主控制与设计; ajax 获取的数据,其结构常常是服务端开发人员说了算,他们面对的业务场景跟前端并不相同,他们往往会为了自己的便利,给出在前端看来很随意的数据结构。

即便是最专业的服务端开发人员,给出最精准的 restful 数据,它也会包含 meta 数据,表明此次返回是否存在错误,如果存在错误,则提供错误信息。除非是 facebook 最近提出的 graphql + relay 模式,不然我们总得对各个来源的数据做一个前期处理。

我们得用专门的处理函数,在各个数据来源里筛选出我们真正需要的数据,不把那些无关紧要的、甚至是脏的数据污染了我们的全局数据对象。

这种对数据来源做萃取工作的函数,就叫 action。它叫这个名字,不是因为它「数据预处理」的功能,而是在 web 应用中所有的数据与状态的变化,几乎都来自「事件」。dom 事件,ajax 成功或失败事件,路由 change 事件, setTimeout 定时器事件,以及自定义事件。任意事件都可能产生需要合并到全局数据对象里的新数据或者线索。

action 跟 event (事件)并不等同。比如在表单的 keyup 事件中,我们只在 e.keyCode 等于回车键或者取消键时,才触发一类 action。dom 事件提供的数据是 event 对象,里面主要包含跟 dom 相关的数据,我们无法直接合并到全局数据对象里,我们只将感兴趣的部分传入 action 函数而已。

所以,是 event 响应函数里主动调用了 action 函数,并且传入它需要的数据。

为什么要有 reducer ?

action 仅仅是预处理,将脏数据筛选掉,它未必产生了可以直接合并到全局对象的数据与结构,它甚至可能只是提供了线索,表示「需要获取某某数据,但不在我这儿」。action 函数的设计,也为它「只提供线索」的做法提供了支持,action 函数必须返回一个带有 type 属性的 plain object

//actions.js
//添加 item 只需要一个 text 字符串数据
export function addItem(text) {
    return {
        type: 'ADD_ITEM',
        text
    }
}
//删除 item 只需要拿到它的 id
export function deleteItem(id) {
    return {
        type: 'DELETE_ITEM',
        id
    }
}

//删除所有已完成事项,不需要额外数据,只需要线索,线索就是 type
export function clearCompleted() {
    return {
        type: 'CLEAR_COMPLETED'
    }
}

如上所示,action 函数的设计理念如下:

  • action 的参数用来筛掉脏数据,调用 action 函数的人,有义务只传入它需要的数据
  • action 返回的 plain object 中包含属性为 type 的常量值
    • 表明这个对象里携带的其他数据应该被如何「再处理」
    • 或者不带其他数据,仅仅启示已有数据需要如何调整,或者需要主动获取哪些数据

reducer 就是迎接 action 函数返回的线索的「数据再处理函数」, action 是「预处理函数」。

因为 action 返回的数据有个固定的结构,所以 reducer 函数也有个固定结构。

//reducer 接受两个参数,全局数据对象 state 以及 action 函数返回的 action 对象
//返回新的全局数据对象 new state
export default (state, action) => {
    switch (action.type) {
        case A:
        return handleA(state)
        case B:
        return handleB(state)
        case C:
        return handleC(state)
        default:
        return state //如果没有匹配上就直接返回原 state
    }
}

如上所示,每个 action.type 的 case (A/B/C),都有一个专门对应的数据处理函数 (handleA/handleB/handleC),处理完之后返回新的 state 即可。

reducer 只是一个模式匹配的东西,真正处理数据的函数,是额外在别的地方写的,在 reducer 中调用罢了。

reducer 为什么叫 reducer 呢?因为 action 对象各种各样,每种对应某个 case ,但最后都汇总到 state 对象中,从多到一,这是一个减少( reduce )的过程,所以完成这个过程的函数叫 reducer

为什么要有 combineReducers ?

reducer 的第一个参数是全局 state 对象。你想想看,全局意味着什么?

state 对象的树形结构必定会随着 web 应用的复杂性而变得越来越深。当某个 action.type 所对应的 case 只是要修改 state.a.b.c.d.e.f 这个属性时,我的 handleCase 函数写起来就非常难看,我必须在这个函数的头部验证 state 对象有没有那个属性。

我们需要这种模式:

//这个 reducer 的 state 属性不是全局 state 本身
//而是它的一个子代属性,比如 state.todos 这个对象
//返回的 new state 也会合并到 state.todos 属性中
export default (state, action) => {
    switch (action.type) {...}
}

如上所示,写起来是普通的 reducer ,但拿到的不是全局 state

实现方法很简单,遍历一个「全是方法」的「函数储存对象」,返回新对象,这个新对象的 key 跟「函数储存对象」一样,它的 value 则是「函数储存对象」的同名方法接受 (state[key], action) 参数的返回值。

var reducers = {
    todos: (state, action) { //预期此处的 state 参数是全局 state.todos 属性
        switch (action.type) {...} //返回的 new state 更新到全局 state.todos 属性中
    },
    activeFilter: (state, action) { //预期拿到 state.activeFilter 作为此处的 state
        switch (action.type) {...} //new state 更新到全局 state.activeFilter 属性中
    }
}

//返回一个 rootReducer 函数
//在内部将 reducers.todos 函数的返回值,挂到 state.todos 中
//在内部将 reducers.activeFilter 函数的返回值,挂到 state.activeFilter 中
var rootReducer = combineReducers(reducers) 

redux 的 combineReducers 源码如下:

//combination 函数是 combineReducers(reducers) 的返回值,它是真正的 rootReducer
//finalReducers 是 combineReducers(reducers) 的 reducers 对象去掉非函数属性的产物
 //mapValue 把 finalReducers 对象里的函数,映射到相同 key 值的新对象中
function combination(state = defaultState, action) {
    var finalState = mapValues(finalReducers, (reducer, key) => {
      var newState = reducer(state[key], action); //这里调用子 reducer 
      if (typeof newState === 'undefined') {
        throw new Error(getErrorMessage(key, action));
      }
      return newState; //返回新的子 state
    });
    //...省略一些业务无关的代码
    return finalState; //返回新 state
 };

相信你也注意到了,mapValue 只是一级深度的映射,目前 redux 并没有提供简便的映射到 state.a.b 一级以上深度的 state 的方法。这是它目前的不足之处。

设想我们做一个移动端 webapp,有很多个 view 在单页中,比如 index 页,list 页,detail 页,redux 提供的「一级分解」一下子就让各个 view 消耗完了,如果还要分割每个 view 的 state ,看起来会很麻烦。

在这里提供几个解决思路:

第一个方案是 superGetter/superSetter

export default (state, action) => {
    switch (action.type) {
        case A:
        let subState = superGetter(state, 'a.b.c') //根据 path 深度获取属性值
        return superSetter(state, 'a.b.c', handleA(subState)) //根据 path 深度设置属性
        default:
        state
    }
}

第二个方案是嵌套 combineReducers

var todosReducers = {
    active: (state, action) => { //拿到全局 state.todos.active
        switch (action.type) {
            case A: //处理 A 场景
            return handleA(state)
            case B: //处理 B 场景
            return handleB(state)
            default:
            return state
        }
    },
    completed: (state, action) => { //拿到全局 state.todos.completed
        switch (action.type) {
            case C: //处理 C 场景
            return handleC(state)
            default:
            return state
        }
    }
}

var todosRootReducer = combineReducers(todosReducers)

var reducers = {
    todos: (state, action) => { //拿到全局 state.todos
        switch (action.type) {
            case A:
            case B:
            case C:
            // A B C 场景都传递给 todosRootReducer
            return todosRootReducer(state, action)
            case D:
            //...handle state
            default:
            return state
        }
    }
}

//rootReducer(state, action) 这里的 state 是真正的全局 state
var rootReducer = combineReducers(reducers)

需要注意的是,reduxcombineReducers(reducers) 的返回值 rootReducers, 总是返回新的 state,它不是修改旧 state,而是创建空对象,然后将 key/value 往上面挂载。只有在 reducers 对象上的 key 才会被迁移。也就是说:

var rootReducers = combineReducers({
    a() {
        //TODO
    },
    b() {
        //TODO
    },
    c() {
        //TODO
    }
})

// newState 只有 a/b/c 三个属性,没有 d 属性,因为 reducers 对象只有 a/b/c
var newState = rootReducers({
    a:1,
    b:2,
    c:3,
    d: 4
}, {
    type: 'TEST'
})

第三个方案更为激进,目前 redux 没有提供,需要修改其源码。这个模式是 transformer (转换器)

//combineReducers 新增第二个参数 transformers
export default combineReducers(reducers, transformers) {
    //..一些预处理工作
    return function combination(state = defaultState, action) {
        var finalState = mapValues(finalReducers, (reducer, key) => {
            var transformer = transformers[action.type] //根据 action.type 来筛选
            var newState
            if (typeof transformer === 'function') {//如果有转换器
                //控制权交给转换器
                newState = transformer(state[key], action, reducer, key)
            } else {
                //否则采取默认模式
                newState = reducer(state[key], action);
            }
            if (typeof newState === 'undefined') {
                throw new Error(getErrorMessage(key, action));
            }
            return newState;
        });
        return finalState
    }
}

有了上面的修改,我们就可以针对 action.type 来选择全局 state 的更新路径了。

var transformers = {
    'ACTION_TYPE1': (state, action, reducer) => {
        return {
            ...state,
            newProp: reducer(state.prop, action) //更新到 newProp 属性中去
        }
    },
    'ACTION_TYPE2': (state, action, reducer) => {
        return {
            ...state,
            otherProp: reducer(state.otherProp, action) //更新到 otherProp 属性中去
        }
    }
}

var rootReducers = combineReducers(reducers, transformers)

如上所示,有了 transformers,不必再嵌套 combineReducers。不过,换个角度看,这个模式只是将原本要在 handleA(state) 里要做的属性查询工作,搬到了 transfromer 中,让 handleA 可以直接处理它需要的 state 对象而已。

总的而言,combineReducers 不是一个必需品,它只是用来分发全局对象的属性到各个 reducer 中去,如果你觉得它太绕,你可以选择直接在每个 handleCase 函数中查询 state 属性,合成 newState 并返回即可。这时候,你只需要一个 reducers 函数,它的 switch 语句处理所有可能的 action.type;想想就是一个超长的函数。

为什么要有 createStore ?

既然 redux 建议只维护一个全局 state ,为什么要搞一个 createStore 函数呢?直接创建一个空对象,然后缓存起来,不断投入到 reducer(state, action) 更新状态不就行了?

这会儿该说到「函数式编程」里的几个概念了。「无副作用函数」与「不变值」。

上面提到的 action 跟 reducer 函数,都是普通的纯函数。对于 action 函数 来说,输入相同的参数无限次,它的返回值也相同。而有了「不变值」,我们得到的好处是,在 react component 的 shouldComponentUpdate(nextProps, nextState) 里,可以直接拿当前 props 跟 nextProps 做 === 对比,如果相等,说明不用更新,如果不相等,则更新到视图。

如果不是返回新 state,只是修改旧 state,我们就很难做到「回退/撤销」以及跟踪全局状态。对比两个数据是否一致,也无法用 ===,而得用 deepEqual 深度遍历来对比值,很耗费性能。

另外, 上面提到的 action 函数,它只是返回一个 plain object 而已,除此之外,它什么也没做。是谁把它传递到 reducers(state, action) 调用?

reducers|state|action 这三个东西由谁来协调?

此时,createStore(reducer, initialState) 呼之欲出;它接受一个 reducer 函数跟 initialState 初始化的全局状态对象,返回几个「公共方法」:dispatch|getState|subscribe。这里我只列举了对我们有重要意义的三个,还剩两个不太重要,可自行参考 redux 文档。

createStore 做的事情在《Javascript 高级程序设计》一书里有讲解,很简明易懂。

//此处为示意,不是 redux 的源码本身
export default createStore(reducer, initialState) {
    //闭包私有变量 
    let currentState = initialState
    let currentReducer = reducer
    let listeners = []

    //返回一个包含可访问闭包变量的公有方法
    return {
        getState() {
            return currentState //返回当前 state
        },
        subscribe(listener) {
            let index = listeners.length
            listeners.push(listener) //缓存 listener
            return () => listeners.splice(i, 1) //返回删除该 listener 的函数
        },
        dispatch(action) {
            //更新 currentState
            currentState = currentReducer(currentState, action)
            listeners.slice().forEach(listener => listener())
            return action //返回 action 对象
        }
    }
}

如上所示,redux 返璞归真的核心代码,没有什么原型继承、面向对象这类绕来绕去的事物。

createStore 的返回值是一个对象,通常我们保存在 store 这个变量名里。其实 store 是一个只有方法,没有数据属性的对象,用 JSON.stringify 去系列化它,得到的是空对象。真正的 state 包含在闭包中,通过公有方法 getState 来获取。

dispatch 方法,是 store 对象提供的更改 currentState 这个闭包变量的唯一建议途径。注意,我是说唯一建议,不是说唯一途径,因为 getSate 拿到的是 currentState 的对象引用,我们还是可以在外头改动它,虽然不建议。

subscribe 方法是一个简单的事件侦听方法,在 dispatch 里更新完 currentState 后调用,不管是什么 action 触发的更新他,它都会调用,并且没有任何参数,只是告诉你 state 更新了。这个方法在后面的提到的服务端同构应用之「镜像 store 」中有妙用。

至此, createStorestore 的全部重要内容都揭示了,它们就是如此简洁。

为什么要有 bindActionCreators ?

通过 createStore 我们拿到了 store, 通过 store.dispatch(action) 我们可以免去手动调用 reducer 的负担,只处理 action 就可以了,一切都很方便。只是,有两种意义上的 action,一种是 action 函数,另一种是 action 对象,action 函数接受参数并返回一个 action 对象。

action 函数是工厂模式,专门生产 action 对象。所以我们可以通过重新命名,更清晰的区别两者,action 函数就叫 actionCreator,它的返回值叫 action

store.dispatch(action) 这里的 action 是一个对象,不是函数,它是 actionCreator 返回的,所以实际上要这样调用 store.dispatch(actionCreator(...args)),很麻烦是吧?

原本的 reducer(state, action) 模式,我们用 createStore(reducer, initialState) 转换成 store.dispatch(action),现在发现还不够,怎么做?再封装一层呗,这就是函数式**的体现,通过反复组合,将多参数模式,转化为单参数模式。

怎么组合?

对于单个 actionCreator ,我们可以轻易地 bindActionCreator

//将 actionCreator 跟 dispatch 绑定在一起
let bindActionCreator => (actionCreator, dispatch) {
  return (...args) => dispatch(actionCreator(...args));
}

//普通工厂函数,返回一个对象
let addItem = text => ({
    type: 'ADD_ITEM',
    text
})

//跟 store.dispatch 绑定起来,成为真正可以改变 currentState 的 action 函数
let addItem = bindActionCreator(addItem, store.dispatch)

对于多个 actionCreator,我们可以像 reducers 一样,组织成一个 key/action 的组合嘛。

export default function bindActionCreators(actionCreators, dispatch) {
  if (typeof actionCreators === 'function') { //如果是单个 actionCreator,绑定一词
    return bindActionCreator(actionCreators, dispatch);
  }
  //返回一个改造过的「函数组合」
  return mapValues(actionCreators, actionCreator =>
    bindActionCreator(actionCreator, dispatch)
  )

如上所示,我们用 bindActionCreators 得到了真正具有改变全局 state 能力的许多函数,剩下的事情,就是将这些函数分发到各个地方,由各个 event 自主调用即可(正如在「为什么需要 action ?」 一节里介绍的)。

redux 工作流程是怎样的?

至此,我们来梳理一下,actionCreator|reducer|combineReducers|createStore|bindActionCreators 这些函数的书写与组合的过程以及顺序。

首先,我们要先设计一些「常量」,因为 action.type 通常是字符串常量。为了便于集中管理,以及利于压缩代码,我们最好将常量放在单独的文件夹里,根据类型的不同放置在不同的文件中。

以 [Isomorphism-react-todomvc] 为例,constants (中译:常量)文件夹里有如下文件:

//ActionTypes.js 真正改动了数据的 actionType 在这里
export const ADD_ITEM = 'ADD_ITEM'
export const DELETE_ITEM = 'DELETE_ITEM'
export const DELETE_ITEMS = 'DELETE_ITEMS'
export const UPDATE_ITEM = 'UPDATE_ITEM'
export const UPDATE_ITEMS = 'UPDATE_ITEMS'

//API.js 服务端接口统一放这里
export const API_TODOS = '/todos'

//SocketTypes.js websocket 也触发了某个 action 改变了 state,单独放这里
export const SERVER_UPDATE = 'SERVER_UPDATE'

//KeyCode.js 键盘的回车键与取消键对应的编码
export const ENTER_KEY = 13
export const ESCAPE_KEY = 27

//FilterTypes.js 只是筛选数据,没有改变 state 的过滤 action 的常量
export const FILTER_ITEMS = 'FILTER_ITEMS'
export const SHOW_ALL = 'SHOW_ALL'
export const SHOW_ACTIVE = 'SHOW_ACTIVE'
export const SHOW_COMPLETED = 'SHOW_COMPLETED'

我们的「常量设计」,可以清晰地反应我们整个 web 应用的业务架构设计;这方面没弄好,随着应用的复杂性增加,会越来越难以维护。当然,比设计常量更靠前的是,设计整个应用的 state 树的结构,这方面不同业务有不同的设计思路,这里无法多做介绍。

由于 todomvc 的业务逻辑很简单,所以它的 state 设计是这样的:

let state = {
    todos: [{
        id: 123,
        text: 'todo item',
        status: false
    }],
    activeFilter: SHOW_ALL
}

有了常量,我们就可以写 actionCreator 了,它们被放置在 actions 文件夹里。

//index.js
import * as types from '../constants/ActionTypes'

export function addItem(text) {
    return { type: types.ADD_ITEM, text }
}

export function deleteItem(id) {
    return { type: types.DELETE_ITEM, id }
}

export function updateItem(data) {
    return { type: types.UPDATE_ITEM, data }
}

export function deleteItems(query) {
    return { type: types.DELETE_ITEMS, query }
}

export function updateItems(data) {
    return { type: types.UPDATE_ITEMS, data }
}

action 是预处理,下一个环节是再处理函数 reducer,它们被放置在 reducers 文件夹里。

//todos.js
import { ADD_ITEM, DELETE_ITEM, UPDATE_ITEM, DELETE_ITEMS, UPDATE_ITEMS } from '../constants/ActionTypes'
import { SERVER_UPDATE } from '../constants/SocketTypes'

export default (state = [], action) => {
    switch (action.type) {
        case ADD_ITEM: //添加 item,放在数组第一个位置
            return [createItem(action.text), ...state]
        case DELETE_ITEM: //删除 item 就是根据 id 过滤掉
            return state.filter(item => item.id !== action.id)
        case UPDATE_ITEM: //更新item 由 updateItem helper 函数执行
            return updateItem(action.data, state)
        case UPDATE_ITEMS: //更新所有 item,就是每个就合并 action.data
            return state.map(item => Object.assign({}, item, action.data))
        case DELETE_ITEMS: //删除 item,过滤掉符合 action.query 对象描述的 item
            return filterItems(action.query, state)
        case SERVER_UPDATE: //服务端推送 action,整个替换掉 todos
            return action.state.todos
        default: //其他没匹配到的 action,返回原 state
            return state
    }
}

//filter.js
import { FILTER_ITEMS, SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED } from '../constants/FilterTypes'

let hashToFilter = {
    '#/': SHOW_ALL,
    '#/active': SHOW_ACTIVE,
    '#/completed': SHOW_COMPLETED
}

export default (state = SHOW_ALL, action) => {
    switch (action.type) {
        case FILTER_ITEMS: //单纯的模式匹配,默认显示 SHOW_ALL
        return hashToFilter[action.active] || SHOW_ALL
        default:
        return state
    }
}

如上所示,todos.js 负责处理 state.todos 属性,filter.js 负责处理 state.activeFilter 属性,所以我们需要用 combineReducers 将它们组织起来。

// reducers/index.js
import { combineReducers } from 'redux'
import list from './list'
import filter from './filter'
//只需要用到一级分解,真是万幸呢
export default combineReducers({
    todos: list,
    activeFilter: filter
})

目前我们有了 actionCreators(就是在 actions 文件夹下的 index.js 的模块输出) 以及 rootReducer 函数(就是上面reducers/index.js的模块输出),接下来,就是用 createStorerootReducer 给吞掉。

// ./store/index.js
import { createStore } from 'redux'
import rootReducers from '../reducers'
export default initialState => {
    return createStore(rootReducers, initialState)
}

我们调用 createStore 拿到 store 之后,就拿到了 store.dispatch,然后用 bindActionCreatorsactionCreators 对象跟 dispatch,粘合在一起。

let dispatchToProps = dispatch => bindActionCreators(actions, dispatch)
//分发给 component,让它从上到下不断分发 action
<View {...dispatchToProps(store.dispatch)} {...props} /> 

如果你用 react-redux ,你就用它提供的 <Provider></Provider + connect 组织单项数据流。

如果你只用 redux,你可以封装一个 render 函数,在 store.subscribe 事件回调里使用。如下所示:

//app.js
let store = createStore(reducers, initialState)
let actions = bindActionCreators(actionCreators, store.dispatch)
let render = () => {
    React.render(
        <Root {...store.getState()} {...actions} >, //传 action,传 state 数据
        document.getElementById('container')
    )
}

store.subscribe(render) //当 state 变化时,重新渲染

如上所示,组织 redux 的流程莫过于:

  • 设计全局 state 的数据结构
  • 设计更改 state 数据的 actionTypes 常量以及其他跟视图展现相关的 actionTypes 常量
  • 根据 actionTypes 常量,书写 actionCreator 。
  • 根据各个 actionCreator 的返回值,涉及 reducer 做数据的最后处理
  • 在有了 reducer 函数之后,createStore(reducer, initState) 得到 store 对象
  • 用 bindActionCreators 函数将 actionCreators 和 store.dispatch 绑定起来,得到一组能修改全局状态的函数
  • 分发各个状态修改函数到各个 DOM 事件中。

为什么需要 applyMiddlewares ?

reducer(state, action) 这个调用方式所反映的 reduceraction 的关系很近,action 就是 reducer 的第二个参数嘛。然而,上面所示的 redux 流程上看,它们却隔着 createStore|store.dispatch|bindActionCreators 三个 API ,才最后汇集到一处。

当我们失去对 reducer 的直接控制权之后,这意味着我们的调试不方便了。原本我们可以像下面那样做:

//我们可以这样:
cosnole.log(state, action) //调用之前
state = reducer(state, action)
cosnole.log(state, action) //调用之后

//虽然现在我们可以这样代替,但这里 action 是我们构造的
//dom 事件里触发的 action,被隐藏得很深,也无法从 store.subscribe 里侦听到,它不传参数
cosnole.log(store.getState(), action) //调用之前
store.dipatch(action)
cosnole.log(store.getState(), action) //调用之后

就算只是为了调试代码,打印出 action 日志,我们也值得设计解决方案。applyMiddlewares 就是一个有用的思路。它的原理很简单,在《JavaScript 高级程序设计》里也有提到,就是模块模式。

export default function applyMiddleware(...middlewares) {
  return createStore => (reducer, initialState) => {
    var store = createStore(reducer, initialState);
    var dispatch = store.dispatch; //拿到真正的 dispatch
    //将最重要的两个方法 getState/dispatch 整合出来
    var middlewareAPI = {
      getState: store.getState,
      dispatch: action => dispatch(action)
    };
    //依次传递给 middleware,让它们有控制权
    var chain = middlewares.map(middleware => middleware(middlewareAPI));
    dispatch = compose(...chain, dispatch); // 再组合出新的 dispatch

    //返回新的 store 对象,其 dispatch 方法已经被传递了很多层
    //每一层都可以调用 dispatch,也可以调用 next 让下一层考虑调用 dipatch
    //最后一个 next 就是 store.dispatch 本身。
    return {
      ...store,
      dispatch
    };
  };
}

然后我们可以这样写中间件了。

//redux-thunk
export default function thunkMiddleware({ dispatch, getState }) {
  return next => action =>
    typeof action === 'function' ? // action 居然是函数而不是 plain object?
      action(dispatch, getState) : //在中间件里消化掉,让该函数控制 dispatch 时机
      next(action); //否则调用 next 让其他中间件处理其他类型的 action
}

注意,在每个中间件里存在两个 dispatch 功能。一个是 { dispatch, getState },这是在 middlewareAPI对象里的 dispatch 方法,另一个是 next,它是 chain 链条的最后一环 dispatch = compose(...chain, dispatch)

如果你不想在将 action 传递到在你之后的中间件里,你应该直接显式地调用 dispatch,不要调用 next。如果你发现这个 action 对象不包含你感兴趣的数据,是你要忽略的 action,这时应该传给 next,它可能是其他中间件的处理目标。

redux 的中间件模式,将 dispatch 的步骤拉长并且细化,使得我们可以处理更多类型的 action,比如带函数的,比如带 promise 的等等,我们可以在真正的 store.dispatch 调用之前,先把看似不合格的 action 对象消化掉,吐出 store.dispatch 能直接调用的数据结构即可。

除了这个 rerdux-thunk (上面的示例真是它的源码,不信请点击这里)中间件之外,还可以写很多不同类型的,其中 redux-logger 就是一开始说的对调试 redux 代码很必要的中间件。

redux 服务端渲染怎么处理?

如果你现在(2015.08.24)去 redux 的官方文档里查阅,你会发现,server rendering 这一块还是不可点击的空白状态。然而,我们既然已经深入到源码层次,自己找出一条途径,也是可以的(只适用于 node.js)。

首先,一开始我们就说过,redux 是无依赖的,所以它可以直接用在 node.js 运行时里。关键在于 react-redux 的服务端渲染方式,它所提供的 connectProvider 组合,扰乱了我们对 react component 的掌控与认知。

我目前的做法是,将跟 redux 有关的所谓的 smart component (智能组件),放到 containers 文件夹里,普通的 react component,放在 components 文件夹里,在客户端时,我们渲染 containers 里的 redux 组件。而在服务端,我们渲染普通组件,redux 组件要传的参数,我们一一构造出来即可。

//server side
let store = createStore(rootReducers, { todos: [] })

store.getComponent = () => {
    let props = stateToProps(store.getState())
    let actions = dispatchToProps(() => {}) //构造空函数给 actions,反正没有 dom 事件
    return React.renderToString(<View {...props} {...actions} />)
}

具体实现可以参考 Isomorphism-react-todomvc 项目。

镜像 store 模式

这里介绍的所谓镜像 store 模式,并非 redux 官方文档里提到的,而是在实践过程中我所发现的有趣用法,大家看看就好,仅供参考,不要误以为是官方推荐模式即可。

思路很简单,既然每个 actionCreator 返回的都是 plain javascript object,它们都是可以被 JSON.stirngify 系列化的。也就是说可以 post 到服务端,如果服务端也有一个同样的 store,它 store.dispatch 一下,不就跟客户端一致了?

这样的话,我们只需要传更轻量的 action 数据,这种做法犹如 graphql 一般。另外,在服务端的 store.subscribe 中我们绑定一个 websocket.emit 函数,就可以把服务端根据 action 所做的数据更新同步到所有浏览器端了。

//store
import { createStore } from 'redux'
import rootReducers from '../public/js/src/index/reducers'
let store = createStore(rootReducers, { todos: [] })
export default store

//router
router.post('/todos', (req, res) => {
    store.dispatch(req.body) //直接 dispatch action 更新 state
    res.json(Object.assign({}, ok, {
        data: req.body
    }))
})

// ./bin/www
let server = require('http').createServer(app);
let io = socketIO(server)
store.subscribe(() => io.emit('change', store.getState())) //服务端推送

// ./index.js
//浏览器端响应一个 dispatch
io().on('change', state => store.dispatch({
    type: SERVER_UPDATE,
    state
}))

// .//reducers/list.js
export default (state = [], action) => {
    switch (action.type) {
        //...other case
        case SERVER_UPDATE:
        return action.state.todos //将整个 todos 数据跟服务端同步起来
        default:
        return state
    }
}

// ./middleware/restful.js
import { API_TODOS } from '../constants/API'
import * as ActionTypes from '../constants/ActionTypes'

export default store => next => action => {
    if (action.type in ActionTypes) { //用中间件模式,筛选有修改数据作用的 action
        fetch(API_TODOS, {
            method: 'post',
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(action) //打包发送到服务端
        })
    }
    //对 action 什么都不做,让浏览器端 action 继续传递
    return next(action) //可以不用等待服务端就更新视图
}

结尾

这篇文章写得长了,就此结尾罢。

总体而言,redux 是一个优秀的新技术,厉害到自己开辟新生态,不容小觑。它也有一些缺陷,比如不容易处理 state 的深度 path 路径问题,比如分发太多 action 到 react component 时,一层层验证 propTypes 的繁琐(虽然别的 flux 实现也有这个问题)等。

比起其他 flux 模式, redux 已然优越。推荐使用。

不用框架,也能用MVC模式组织代码

用MVC模式组织代码

前言

MVC这个名词,在前端领域还处于意义不明确的阶段。打着MV*名号的框架层出不穷,概括MVC的文章也让人应接不暇。然而究竟什么是MVC,那些作者各执一词。

winter 做了下正本清源的工作:

既然经典MVC模式与前端的view.onclick天然的互斥,不再适用;既然大家都在打扮MVC,本文也尝试重新演绎。介绍一种以ModelViewController为命名的代码组织方案。

这个方案给我带来了良好的编程体验,并且几乎立即可以应用在所有项目中,不管历史遗留的代码多么庞杂,新增的业务需求都能按照新方案进行组织。

Talk is cheap, show me the code

Github的todomvc项目,提供了各种以MV*为名号的框架的实现,帮助前端工程师选择合适的框架或库。其中的vanillajs版本,是一个无框架无库的原生JavaScript实现;它也是按照MVC的理念来组织代码。框架或库不是MVC模式的必需品,只是说有了相应的框架与库,写起来更为便利。

本文也准备了一个原生实现todos-vanillajs,可作MVC模式参考案例。

在线体验地址:http://lucifier129.github.io/todos-vanillajs/index.html

MVC模式

一个模块是一个文件夹

在一些nodejs前端模块化编程的介绍中,常常能看到一个文件就是一个模块的说法,用module.exports的值作为模块输出。暂且把这类模块叫做nodejs模块AMD/CMD模块

我们真正关注的模块,其实是业务模块,它是指与DOM相关的视图模型、数据模型与事件交互模型的总和。比如一个页面的header模块,它包含html模板填充的数据以及随着滚动高度而固定在顶部的事件交互等结构。

业务模块不等价于AMD/CMD模块!如果产生这个误解,可能将所有东西塞一个js文件。只把html模板提取出来,也不足够。

一个业务模块应该是好几个AMD/CMD模块的组合,它是一个文件夹,包含好几个AMD/CMD模块文件,其中:

  • view.js文件对应视图模型,存储html模板,并提供根据数据输入渲染视图的方法、从视图中获取并输出数据的方法
  • model.js文件对应数据模型,提供数据存储与数据处理的所有方法
  • controller.js文件对应事件交互模型,提供事件绑定与组织视图模型和数据模型的所有方法
  • app.js对应业务模块出口文件
  • README.md是一个描述业务逻辑markdown文件,随业务逻辑变更而更新
  • 其它文件如package.jsondata_mock.json根据实际需求决定是否添加

并且:

  • 当视图模型里包含太多内容时,view也应该变成文件夹,里面包含类似template.jstodo-list.js, filter.js以及出口文件view.js
  • viewmodel绝不知道事件何时绑定,何时触发,一切事件逻辑都放在controller.js
  • view只对html模板进行get/set数据
  • model处理数据输入,数据转换、数据储存与数据输出
  • ajax行为,属于controller,不属于model;在success回调函数里调用model提供的方法处理数据

controller.js的一般形式如下:

function Controller(View, Model) {
    this.View = View
    this.Model = Model
}

Controller.prototype = {
    init: function() {
        //初始化
    },
    listen: function() {
        //事件绑定
    }
}

controller.js拿到了视图模型与数据模型,也就掌握了如何处理数据、如何渲染视图的所有方法;关于数据处理与视图渲染的时机问题,则由设计事件绑定来完成。所以它是事件交互控制器

定义和调用分离

todomvc是一个功能简单的页面,因此用一个js文件夹足以。其中的view.jsmodel.js以及controller.js都只是定义了一些方法接口, app.js这个出口文件才产生了调用。

//view.js
app.View = {
        TodoList: TodoList,
        TodoElem: TodoElem,
        Counter: Counter,
        Footer: Footer,
        ToogleAll: ToogleAll,
        Filters: Filters
    }

视图只有7个信息渲染点,用6个定义了渲染方法。没有产生调用。

//model.js

    function Model(name) {
        this.name = name
        this.todos = localStorage.getItem(name)
        if (this.todos) {
            this.todos = JSON.parse(this.todos)
        } else {
            this.todos = []
        }
    }

    Model.prototype = {
        getTodo: function(id) {
            //根据id获取todo项
        },
        getAll: function() {
            //获取所有todo项
        },
        getActive: function() {
            //获取未完成的todo项
        },
        getCompleted: function() {
            //获取已完成的todo项
        },
        addTodo: function(todo) {
            //添加新的todo项
        },
        removeTodo: function(id) {
            //根据id删除todo项
        },
        save: function() {
            //保存数据
        }
    }

数据模型Model提供了所有跟数据处理相关的方法。没有产生调用。

//controller.js

    function Controller(View, Model) {
        this.View = View
        this.Model = Model
    }

    Controller.prototype.init = function() {
        //初始化
    }

    Controller.prototype.listen = function() {

        //绑定事件代理
        //每个事件中,涉及渲染的用View提供的方法,涉及数据的用Model提供的方法

        tools.$listen('change', '#new-todo', function() {
            //输入框的change事件中,添加新todo项
        })

        tools.$listen('keyup', '#new-todo', function(e) {
            if (e.keyCode === ENTER_KEY) {
                //输入框聚焦时,按回车键也添加新todo项
            }
        })

        tools.$listen('dblclick', '#todo-list label', function() {
            //todo-list里双击进行内容编辑
        })

        tools.$listen('change', '#todo-list .edit', function() {
            //内容编辑框的change事件,标志完成编辑
        })

        tools.$listen('keyup', '#todo-list .edit', function(e) {
            var keyCode = e.keyCode

            if (keyCode === ESCAPE_KEY || keyCode === ENTER_KEY) {
                //内容编辑框按回车键,或者ESC退出键,标志完成编辑
            }
        })

        tools.$listen('change', '#todo-list .toggle', function() {
            //todo-list每一项提供的checkbox,其change事件中切换[未完成-已完成]状态
        })

        tools.$listen('change', '#toggle-all', function() {
            //该checlbox的切换所有todo项的状态
        })

        tools.$listen('click', '#todo-list .destroy', function() {
            //每个todo项的视图中,提供删除按钮,其click事件触发删除
        })

        tools.$listen('click', '#clear-completed', function() {
            //一个按钮,其click事件触发时,清除所有已完成的todo项
        })

        //dom ready时更新页面数据
        document.addEventListener('DOMContentLoaded', this.update.bind(this), false)
        //hashchange时,更新页面数据
        window.addEventListener('hashchange', this.update.bind(this), false)
        //页面关闭时,保存数据到localStorage
        window.addEventListener('beforeunload', this.model.save.bind(this.model), false)
    }

    Controller.prototype.otherMethod = function() {
        //其它方法,用于封装可复用的model与view的交互方式
    }

事件交互控制器虽然写了与业务逻辑耦合严重的硬编码,比如事件代理的各个特定的selector;但是它仍然封装在实例方法中,不形成调用。

//app.js
app.todos = new app.Controller(app.View, app.Model).init()

真正的调用,发生在出口模块中,并且也是因为此app.js是整个页面的出口模块。假设它只是其中一个业务模块,很多时候也不应调用init方法,而是放到页面出口模块中,择时机初始化。

定义与调用分离的好处:

  • 其它业务模块可以无副作用的复用你的view/model/controller提供的方法
  • 定义与调用分离,更容易促使工程师写出可复用的、面向对象的代码

尾声

以上所谓的MVC模式,算不上最佳实践,只是一个经验分享。如果你有好的建议,或者更好的方案,欢迎指出与分享。

ESNEXT 原生框架范式探究(01)

前言

托付于 Node.jsHMTL5JavaScript 发展得越来越快。

几年前,就有人提出「你或许不需要 jQuery」 ,近期也有「你可能不需要 underscore」以及 Hax 的 nodash 仓库。

正当风靡的 ReactJs 提出 Flux 架构后,社区涌现出许多不同版本跟理念的 Flux 实现,目前风头最劲的要数基于 ES7+redux,它的理念除了hot loading之外,最重要的就是pure function(纯函数)。

作为一门语言,javascript 的自理能力不断增强;那么势必存在一种组织范式,使得不用框架,也能像之前有框架辅助那样,写出模块化、组件化以及语义清晰的代码。

我对原生框架范式的思考,会在esnext-framework仓库里积累。

所谓的原生框架范式,主旨是「用理念代替 API;如非必要,勿增 API」。我目前的探索方式是,在 babelstage = 0 模式提供的一切便利下,寻找一个舒适的前端编程模式;每一个范式探究,都以一个TODOMVC DEMO为产物和体现。

本文是小试牛刀的[范式01]。

es6.template 视图模板组件化

在 Mozilla 的 ES6 In Depth: Template strings 文章里提到:

Template strings are no replacement for Mustache and Nunjucks, partly because they don’t have built-in syntax for loops or conditionals.

然后演示了如何用 Tagged templates (标签模板提供模板字符串的循环和条件语句),看起来自己打自己脸,好不快乐。

我认为,es6.template 没有提供循环和判断语句,并不是大问题,因为 ${expression} 语法支持任何 javascript 语句,甚至可以嵌套 template 字符串;我们自己写循环,操作空间更强。

在 reactjs 的 jsx 语法里也可以写任意纯 js 语句,我们早就见怪不怪,并且乐得其所。与其自己制造方言,倒不如用纯 js 语法。

所以,我认为 es6.template 起码可以作为轻量级 web 应用的视图模板的选择之一。

它的大概写法如下:

//component/todo.js
export default data => {
    let { completed, time, id, title } = data
    return `
<li class="${ completed }" data-id="${ id }" data-helper="todo" title="${ time }">
    <div class="view">
        <input class="toggle" type="checkbox" ${ completed ? 'checked' : '' }>
                <label>${ title }</label>
                <button class="destroy"></button>
    </div>
    <input class="edit">
</li>
    `
}

component/todo.js返回一个渲染字符串函数,在 component/todos.js 循环展开,这样实现组合作用。

import todo from 'component/todo'

export default data => {
    return `
<ul id="todo-list">
    ${ data.map(todo).join('') //纯 js 循环语句 }
</ul>
    `
}

es7.functionBind 与 函数式 helper

es7.functionBind 暂不了解的可以点击这里。这个特性,算不上强大,只是语法糖性质,编译到 ES3 环境也能正常运行。

然而,有了::语法,我们的代码可以变得更富有表现力,我们的关注点更能纯粹。甚至,我们整个思维模式都可能因其而改变。

虽然 js 模块化开发已经有几年的历史,但直到今天,我们也还在继续命名空间思路。$_ 符号四处可见。trine 开始做出改变,它用 :: 取代 _ ,让代码更整洁,显得更函数式。我们可以在这条道路上,走得更远。

一直以来,往原型链上添加属性和方法,都是很诱惑的事情;它的副作用是覆盖原生行为与污染全局变量带来的不确定性。如果用上 :: 的话,我们就可以保持 this method 的顺序和格式,得到整洁的外观,而又不会污染代码运行时的确定性。

我们只是从this.method( )变成this::method(),别提多方便了。

我们可以这样编写我们的函数方法:

//type helper
export let isArray = function() {
    return Array.isArray(this)
}

export let isFunction = function() {
    return Object.prototype.toString.call(this) === '[object Function]'
}

export let isObject = function() {
    return Object.prototype.toString.call(this) === '[object Object]'
}

export let isString = function() {
    return Object.prototype.toString.call(this) === '[object String]'
}

export let isNumber = function() {
    return Object.prototype.toString.call(this) === '[object Number]'
}

export let isBoolean = function() {
    return Object.prototype.toString.call(this) === '[object Boolean]'
}

然后这样调用它们:

import { isArray, isFunction, isString, isObject } from 'helper'

'hello world'::isString() //true
[1, 2, 3, 4]::isObject() // false
(123123)::isNumber() // true

看起来,任意 js 数据类型,都具备了类型检测方法一般。这种模式,我称之为 helper,像原型方法一样编写,用 :: 调用。

helper 增强了 js 语言,以无副作用或者少副作用的方式,提供给所有数据类型有用的自定义方法。它的魔力,不知上面所示,我们再来看看下面的例子:

//jqueryHelper.js
import $ from 'jquery'
import { isString } from './helper'

let helper = {}
export default helper

Object.keys($.fn).forEach(key => {
    helper[key] = function(...args) {
        if (this::isString()) {
            return $(String(this))[key](...args)
        }
        return $(this)[key](...args)
    }
})

我们把所有 $.fn 方法导出为 helper 模式,于是可以像下面那样使用:

import { appendTo, attr } from 'jqueryHelper'

//这个做法过时了
$('body').html('<h1>hello world</h1>')

//酷酷的
'<h1>hello world</h1>'::appendTo('body')

//原生dom api 冗长啰嗦
document.getElementsByTagName('script')[0].getAttrubite('type')

//酷酷的
'script'::attr('type')

jquery 简化了 dom 操作,:: 简化了命名空间,代码更整洁,更声明式。对于 underscore 库,也可以做相同的 helper 转换,可用方法更丰富了。

你以为这样就完了?

函数式风格,终究还是要在函数下手,看看下面这种:

//柯里化
export let currying = function(first) {
    return (...args) => {
        this(first, ...args)
    }
}

//反柯里化
export let uncurrying = function() {
    return (context, ...args) => {
        this.apply(context, ...args)
    }
}

//管道流式拼接
export let pipe = function(next = noop) {
    if (this::isFunction()) {
        return (...args) => {
            return next(this(...args))
        }
    } else if (this::isArray()) {
        return this.reduce((prev, cur) => prev::pipe(cur))
    }

}

//promise 流式拼接
export let then = function(next) {
    if (this::isFunction) {
        return (...args) => {
            return Promise.resolve(this(...args)).then(next)
        }
    } else if (this::isArray()) {
        return this.reduce((prev, cur) => prev::then(cur))
    }
}

然后,我们的函数就可以像这样拼接起来了:

//直接将数据模型的反应函数与渲染函数绑定起来,数据发生改变,视图就做出反应
let onAction = [::this.model.onAction, ::this.render]::pipe()

//::this.model.onAction === this.model.onAction.bind(this.model)

//事件代理,事件类型与代理 selectory 存放在 key 里,事件回调存放在 value 里
let events = {
            'change : #new-todo'(e) {
                //onAdd 为 helper,this 值为 e.target,从中获取到新的 todo title
                //onAdd 返回 {type: 'add',title: value }
                //onAction 接受一个 action 对象,根据 action 的 type 做不同反应
                //类似于 reactjs 的 flux 模式
                e.target::onAdd::pipe(onAction)()

                //e.target::onAdd === onAdd.bind(e.target)
            },
            'change : #todo-list .edit'(e) {
                //事件绑定变得干净
                e.target::onEdited::pipe(onAction)()
            },
            'keyup : #new-todo'(e) {
                if (e.keyCode === ENTER_KEY) {
                    //可维护性强,增加业务逻辑,只是 pipe 多一个 helper 纯函数
                    e.target::onAdd::pipe(onAction)()
                }
            },
            'dblclick : #todo-list label'(e) {
                e.target::onEditing()
            },
            'keyup : #todo-list .edit'(e) {
                let keyCode = e.keyCode
                if (keyCode === ENTER_KEY || keyCode === ESCAPE_KEY) {
                    e.target::onEdited()
                }
            },
            'change : #todo-list .toggle'(e) {
                e.target::onToggle::pipe(onAction)()
            },
            'click : #todo-list .destroy'(e) {
                e.target::onRemove::pipe(onAction)()
            },
            'click : #clear-completed'(e) {
                onAction({
                    type: 'clear'
                })
            },
            'change : #toggle-all'(e) {
                onAction({
                    type: 'toggleAll',
                    completed: e.target.checked
                })
            }
        }

就这样,通过函数式 helper ,我们的代码更清晰。

逻辑分组

在[范式01]中,代码根据性质做了分组与分类

  • component 组件化的视图
  • helper 函数式纯逻辑
  • directive 将数据渲染到视图的自定义指令
  • method 响应 dom 操作,从视图中获取数据,返回数据对象的纯函数
  • model 数据模型类,响应 method 返回的数据对象
  • app 出口模块类,协调上面四个代码类型,拼接业务逻辑。

尾声

以上就是小试牛刀的[范式01],其代码仓库地址是:https://github.com/Lucifier129/esnext-framework/tree/patten1

在 examples 文件夹里可以找到 todos-vanillajs 与 todos-jquery 查看示例代码。

欢迎大家一起来维护这个项目,寻找更好的前端编程范式。

理解正则表达式

理解正则表达式

在我初学正则表达式的时候,走了一些弯路,强行记忆了很多符号和用法。

等到我有更深入的理解的时候我发现,从翻译和概念的角度上切入,学习起来会顺畅得多。

本文以JavaScript里的正则表达式为例,讲解其中的关键要素。希望能帮助到初学者。

注:也只限于阐述关键要素,不会事无巨细地展开。

何为正则表达式?

在中文语境里,「正则」两个字有点让人发怵,仿佛高深数学或物理中的「正则化」和「归一化」,抽象而难懂。

其实放到英文里,它是regular expression,而regular有「规律、规范、整齐、合格、正规」等意味,「正则」只是其中一种翻译。

不把它翻译成一个词组,而翻译成一句话,大致是:表达规范和规则的句子。

这里的规范和规则,指的是一个字符串的形式规则。

至于JavaScript里的 RegExp 构造函数,是Regular Expression的前三个字母缩写。

正则表达式的格式

JavaScript里,正则表达式有两个构造方式,一个是通过RegExp这个构造函数创建实例,另一个是正则表达式字面量写法。

var regexp1 = new RegExp('hello regular expression')
var regexp2 = /hello regular expression/

// test 方法,测试给定的字符串是否符合正则表达式所描述的字符串格式
regexp1.test('hello regular expression') // -> true
regexp1.test('hello word') // -> false

// exec 方法,是 execute 这个单词的缩写,「执行」。返回给定的字符串中符合「正则表达式所描述的字符串格式」的部分
regexp2.exec('hello regular expression') //  返回 'hello regular expression'
regexp2.exec('hello regular expression, more words') //  只返回 'hello regular expression',其它部分不匹配

正则表达式里的元字符

元字符听起来也很抽象,其实换个例子就容易理解:学习如何学习,叫元学习;关于知识的知识,叫元知识。

元字符,则是描述字符的字符,比如,数字,字母,空格,换行等。

元编程,就是能生成代码的代码,在 Javascript 构造符合语法的字符串,放到eval(code)里运行一下,你就在元编程了。

然后看看元字符、元编程的英文:meta-charactermeta-programming,对 meta 长个记性,怯魅。

列举几个元字符。元字符大多以反斜杠开头 \,因为前面展示的「正则表达式字面量」写法里,用的是两个斜杠包裹,所以得用反斜杠或其他标识符。

  • \d,匹配单个数字;d 是 digit 这个单词的缩写,它的中文意思就是「数字」
  • \w,匹配单个单词字符,w 是 word 的缩写,就是字母 a-z,数字 0-9,不包括逗号、句号、加减乘除号、括号等。
  • \s,匹配单个空白字符,s 是 space 的缩写,就是空白的意思。
  • \n,匹配换行符,n 是 newline 的缩写,中文就是换行。
  • \r,匹配回车符,r 就是 return 的缩写,回车在这里就是它的中文意思。
  • \t,匹配制表符,就是 tab 键打出来的一串用以缩进的空白字符,tab 是 tabel 的缩写,table 就有表格和制表的意思。
  • \b,匹配单词边界,b 是 boundary 的缩写,中文就是边界的意思。

如你所见,所谓的元字符,就是反斜杠加单词缩写,来表征某个字符类型。这就是它们的设计原则。

正则表达式里的量词

元字符大多只能表示单个字符的类型。

我们还需要量词,以表示「有,有0到多个,有至少一个,有n个以上,有n到m个,以某个字符开头,以某个字符结尾等」。

这时你可以停下来,稍作思考,让你来设计,你会设计成什么样?

Javascript的设计如下:

  • n+,至少1个 n 类型的字符
  • n*,0到多个 n 类型的字符
  • n?,0 或 1 个 n 类型的字符
  • n{X},X 个 n 类型的字符
  • n{X,Y},X 到 Y 个 n 类型的字符
  • n{X,},至少 X 个 n 类型的字符
  • n$,以 n 类型的字符结尾
  • ^n,以 n 类型的字符开头

如你所见,大致是一些类似数学里表达区间的意思。

正则表达式里的表达式

你可以戏谑地说它是「元表达式」。

其实,它们也是描述范围的,只是不是所有范围都是关于某个字符类型n 的数量和出现位置,有些范围跟多个字符组成的集合有关。

比如,在这几个字符类型之内,在这几个字符类型之外的,便利地表示 26 个字母,便利地表示 10 个数字字符。

Javascript的设计如下:

  • [abc],匹配单个字符,它是abc的集合的元素
  • [^abc],匹配单个字符,它不是abc的集合的元素
  • [0-9],匹配单个字符,它是从0到9这个集合的元素
  • [a-z],匹配单个字符,它是26 字母这个集合的元素
  • (red|blue|green),匹配多个连续字符,它是 red blue green 这三个词的集合的元素

小试牛刀

匹配一个电话号码,形式如 020-88813243。

简单版本,(开头)三个数字+一个横杠+八个数字(结尾),就是/^\d{3}-\d{8}$/

需求变化,只匹配 020 开头的电话号码,就是/^020-\d{8}$/

需求变化,支持分机,分机为 5 个数字,加后缀,就是/^020-\d{8}-\d{5}$/

需求变化,电话号码可以是7个,用区间量词,就是/^020-\d{7,8}-\d{5}$/

需求变化,有可能没有分机,用区间量词,中括号包裹住分机为一组,后面加个问号,表示0或多个,就是/^020-\d{7,8}(-\d{5})?$/

需求变化,区隔符可能是横杠,也可能是星号或空格,用集合表达式,就是/^020[-*\s]\d{7,8}([-*\s]\d{5})?$/

结语

在我们理解了正则表达式的概念和设计思路之后,剩下的,就是查文档和寻找模式的工作了。

实在有难题,网上也可以搜索到现成的坚实的正则表达式可用。这里面的门道还是很多的,在此我们入个门,打个基础即可。

漫谈 JavaScript里的对象、继承、作用域与其它

前言

工具:Babel 在线编译:https://babeljs.io/repl/

好用的对象字面量与进阶

「一切都是对象」是简洁概念,但是像下面这样创建实例太辛苦。

var person = new Object()
person.firstName = 'Jade'
person.lastName = 'Gu'
person.name = 'Jade Gu'

我们想要更直观的方式,如:

var person = {
    firstName: 'Jade',
    lastName: 'Gu',
    name: 'Jade Gu'
}

如你所见,name属性,无非是firstNamelastName的组合,而上面我却得重复两次,实在不便。

我们需要一个「计算属性」,在需要用到时,它自行根据已有属性计算出结果。

之前的 JavaScript 没有提供便捷的方法,我们得用下面这种冗长做法。

var person = {
    firstName: 'Jade',
    lastName: 'Gu'
}

Object.defineProperty(person, 'name', {
    get: function() {
        return [this.firstName, this.lastName].join(' ')
    }
})

person.name // Jade Gu

只是写个姓名而已,竟然难以找到编写的舒服姿势,很难受是吧?

ES2015 带了了一些福音。让我们可以这样写:

var person = {
    firstName: 'Jade',
    lastName: 'Gu',
    get name() {
        return [this.firstName, this.lastName].join(' ')
    }
}

经过 Babel 编译后是这个模样:

'use strict';

var person = Object.defineProperties({
    firstName: 'Jade',
    lastName: 'Gu'
}, {
    name: {
        get: function get() {
            return [this.firstName, this.lastName].join(' ');
        },
        configurable: true,
        enumerable: true
    }
});

如果只写一个人名,那么上面的足够了;但实际上我们需要用到很多人名,每次都写get name,心很累。

我们要封装,对于人名做「最小数据关注量」。比如下面:

function createPerson(firstName, lastName) {
    return {
        firstName: firstName,
        lastName: lastName,
        get name() {
            return [this.firstName, this.lastName].join(' ')
        }
    }
}

var person1 = createPerson('Jade', 'Gu')
var person2 = createPerson('Hehe', 'Da')

这个方式叫「工厂模式」。在这个场景中,它有两大不美。其一是createPerson名字冗长,其二是get name每次都会创建一个新的函数。

我们想要「最小打字量」跟「最小内存占用」。下面这种方式,更接近我们的目标:

function Person(firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
}

Object.defineProperty(Person.prototype, 'name', {
    get: function() {
        return [this.firstName, this.lastName].join(' ')
    }   
})

var person1 = new Person('Jade', 'Gu')
var person2 = new Person('Hehe', 'Da')
person1.name // "Jade Gu"
person2.name // "Hehe Da"

原型上的计算属性,也能影响到实例。当实例自身没有name属性时,JS 引擎查找原型上有没有,原型有个同名计算属性,被查找时也就启动了计算,然后返回结果。计算 name 的函数只需要一份,放在原型对象上就可以了。省了内存。

这样我们用new取代了createnew Person而不是createPerson,省了创建时的打字量。

但如你所见,在定义时,不够美观,感觉给Person.prototype做特殊处理,而不是很自然的定义Person

name当然是Person很自然要拥有的属性之一,为什么要在定义时要显示地用别的函数(Object.defineProperty)?

所以,我们要用 ES2015 的语法

class Person {
    constructor(firstName, lastName) {
        this.firstName = firstName
        this.lastName = lastName
    }
    get name() {
        return `${this.firstName} ${this.lastName}`
    }
}
const person1 = new Person('Jade', 'Gu')
const person2 = new Person('Hehe', 'Da')

这下感受自然得多。再来感受一下python的语法:

class Person(object):
    def __init__(self, firstName, lastName):
        self.firstName = firstName
        self.lastName = lastName
    @property
    def name(self):
        return '%s %s' % (self.firstName, self.lastName)

person1 = Person('Jade', 'Gu')
person2 = Person('Hehe', 'Da')

在 ES2015 出现之前,我觉得 python 的语法很干净。而现在,我更倾向于 ES2015。

在 python 中,只要用装饰符@property,就可以更自然的定义计算属性,将一个 name 方法调用当做属性来使用。

可惜在 ES2015 中,还没有装饰符。然而可期的是,ES2016 可能有。现在用 Babel 也可以书写了。

function readonly(target, name, descriptor) {
    return {
        get: descriptor.value
    }
}

class Person {
    constructor(firstName, lastName) {
        this.firstName = firstName
        this.lastName = lastName
    }
    @readonly
    name() {
        return `${this.firstName} ${this.lastName}`
    }
}
const person1 = new Person('Jade', 'Gu')
const person2 = new Person('Hehe', 'Da')

好吧,看起来还不如get name。在目前这个简单场景内没有优势,但更复杂的情况下,装饰符能很好地分离复杂度,将那些固定的、与业务无关的特性处理工作,移出「定义点」,让「定义点」代码更干净与直观。

旨在共享数据的原型与作用域

如前一节所示,在 ES2015 之前,JavaScript 里定义最简单的人名也像「醉汉走路」。在七扭八歪的代码里,才终于达到了目标。

我们的目标是:更小打字量,更小内存消耗。

这意味着,我们要共享很多东西,才能抑制暴涨。

「继承与组合」是共享的两大方式。

共享的原理是:「世界终究是孙子们的」。不信,你看:

function grandpa() {
    var grandpa_money = 1000000

    function father() {
        var father_money = 500000

        function me() {
            var my_money = 300000

            function child() {
                var child_money = 100000

                function grandson() {
                    var grandson_money = 100
                    grandson_money += child_money
                    grandson_money += my_money
                    grandson_money += father_money
                    grandson_money += grandpa_money
                    child_money = my_money = father_money = grandpa_money = 0
                    child = me = father = grandpa = null
                    console.log(grandson_money)
                    console.log(child_money, my_money, father_money, grandpa_money)
                    console.log(child, me, father, grandpa)
                }
                grandson()
            }
            child()
        }
        me()

    }
    father()
}
grandpa()

祖父拥有1000000,创造了父亲;

父亲拥有500000,创造了我;

我拥有300000,创造了儿子;

儿子拥有100000,创造了孙子;

孙子拥有100,把祖上的前都败光,并且欺师灭祖。

这就是 JavaScript 里的作用域链;根据创建时间与环境,确定可支配变量的范围。

正如我们自诩拥有的「五千年文化遗产」一样,最后创建的,最里层的grandson可支配祖上所有变量。

这样设计的好处是,如果我需要的数据已经在内存中,那么就不必重复创建。然后两个平级的函数之间无法互相访问对方的私有变量,就做到了「数据隐私控制」。

上面是作用域链层面的数据共享,下面看看原型链层面的数据共享。

var Ancestors = {
    '人的成长': '生老病死',
    '地球位置': '世界中心',
    '地球形状': '天圆地方',
    '人类起源': '上帝造人'
}
var SomeoneA = Object.create(Ancestors)

Object.assign(SomeoneA, {
    '地球位置': '太阳系中心',
    '地球形状': '地球是圆的',
    '人类起源': '物种演化'
})

var SomeoneB = Object.create(SomeoneA)

Object.assign(SomeoneB, {
    '地球位置': 'https://zh.wikipedia.org/wiki/%E5%9C%B0%E7%90%83%E5%9C%A8%E5%AE%87%E5%AE%99%E4%B8%AD%E7%9A%84%E4%BD%8D%E7%BD%AE',
    '地球形状': 'https://www.google.com/search?q=%E5%9C%B0%E7%90%83%E5%BD%A2%E7%8A%B6&es_sm=122&tbm=isch&tbo=u&source=univ&sa=X&ved=0CB4QsARqFQoTCJv1iZvb4cYCFRIakgod5XAAyg&biw=1680&bih=912',
    '人类起源': '裸猿/走出非洲大草原'
})

原型的数据共享方式,类似于科学知识的发展。

你没有新发现,你所有知识都来自于前人的研究成果。

你有新发现,就以你的新发现为准。你推翻(删除)了你的新发现,还是以祖先的为准。

你访问SomeoneB能得到最新的数据,其中「人的成长」是在这里是颠扑不破的数据,还是沿用祖先的。

你要获取某一时期的数据,访问那个时期的对象即可。如此,获取数据以及数据之间的关系,就很便利了。

然而,知识可以共享与迭代,技能呢?

我如何在 JavaScript 中,写出简易的物种演化模型?

演化是一点点的积累,不是覆盖,不能每个物种都重新发明所有技能;所以,class就显得很重要

//只会说呵呵哒的「人」
class H {
    constructor() {
        this.type = 'h'
    }
    say() {
        console.log('呵呵哒')
    }
}

class Hu extends H {
    constructor() {
            super()
            this.type = 'hu' //覆盖你
        },
        think() {
            return Math.random() > 0.5 ? '么么哒' : null
        }
    say() {
        //有想法说想法,没想法呵呵哒
        if (!this.think) {
            super.say()
        } else {
            console.log(this.mind)
        }
    }
}

class Hum extends Hu {
    constructor() {
        super()
        this.type = 'hum'
        this.memory = {}
    }
    remember(information) {
        this.memory[new Date().getTime()] = information
    }
    think(key) {
        return key ? this.memory[key] : super.think()
    }
}

class Human extends Hum {
    constructor() {
        super()
        this.type = 'human'
    }
    think(key) {
        var result = super.think(key)
        return result.includes('Miss Right') ? 'I Love You' : result
    }
}

技能的传承跟数据的传承,性质不同。

我们可能不再需要祖先的错误知识,但还是无法离开祖先遗传下来的呼吸能力。

结语

嗯,这就是我眼中的 Javascript ,一副醉汉模样,好在有 Babel ,终于快清醒过来了。

这一醉,就是20年。

浅谈 JavaScript 处理树形结构的几个场景与方案

作者: 工业聚
日期:2015-06-12

前言

近日,Mac 下著名软件 Homebrew 的作者,因为没解出来二叉树翻转的白板算法题,惨遭 Google 拒绝,继而引发推特热议。

在 JavaScript 中也有很多树形结构。比如 DOM 树,省市区地址联动,文件目录等; JSON 本身就是树形结构。

很多前端面试题也跟树形结构的有关,比如在浏览器端写遍历 DOM 树的函数,比如在 nodejs 运行时遍历文件目录等。

这里演示用 JavaScript 遍历树形结构的几种策略。

场景1:遍历 DOM 树

方案1:递归模式

function walkDom(node, callback) {
    if (node === null) { //判断node是否为null
        return
    }
    callback(node) //将node自身传入callback
    node = node.firstElementChild //改变node为其子元素节点
    while (node) {
        walkDom(node, callback) //如果存在子元素,则递归调用walkDom
        node = node.nextElementSibling //从头到尾遍历元素节点
    }
}
walkDom(document, function(node) {console.count()}) //包含document节点
document.querySelectorAll('*').length //数量比上面输出的少1,因为不包含document节点

将上述代码黏贴到任意页面的控制台 console 中执行。

方案2:循环模式

function walkDom(node, callback) {
    if (node === null) {
        return
    }
    var stack = [node] //存入数组
    var target
    while(stack.length) { //数组长度不为0,继续循环
        target = stack.shift() //取出元素
        callback(target) //传入callback
        Array.prototype.push.apply(stack, target.children) //将其子元素一股脑推入stack,增加长度
    }
}
walkDom(document, function(node) {console.count()}) //包含document节点
document.querySelectorAll('*').length //数量比上面输出的少1,因为不包含document节点

在循环模式中,shift方法可以换成pop,从尾部取出元素;push方法可以换成unshift从头部添加元素。不同的顺序,影响了是「广度优先」还是「深度优先」。

场景2:在 nodejs 运行时里遍历文件目录

子场景1:同步模式

方案1:递归

var fs = require('fs')
var Path = require('path')

function readdirs(path) {
    var result = { //构造文件夹数据
        path: path,
        name: Path.basename(path),
        type: 'directory'
    }
    var files = fs.readdirSync(path) //拿到文件目录下的所有文件名
    result.children = files.map(function(file) {
        var subPath = Path.resolve(path, file) //拼接为绝对路径
        var stats = fs.statSync(subPath) //拿到文件信息对象
        if (stats.isDirectory()) { //判断是否为文件夹类型
            return readdirs(subPath) //递归读取文件夹
        }
        return { //构造文件数据
            path: subPath,
            name: file,
            type: 'file'
        }
    })
    return result //返回数据
}

var cwd = process.cwd()
var tree = readdirs(cwd)
fs.writeFileSync(Path.join(cwd, 'tree.json'), JSON.stringify(tree)) //保存在tree.json中,去查看吧

将上面的代码保存在 tree.js 中,然后在当前文件夹打开命令行,输入node tree.js,目录信息保存在生成tree.json文件中。

方案2:循环

var fs = require('fs')
var Path = require('path')

function readdirs(path) {
    var result = { //构造文件夹数据
        path: path,
        name: Path.basename(path),
        type: 'directory'
    }
    var stack = [result] //生成一个栈数组
    while (stack.length) { //如果数组不为空,读取children
        var target = stack.pop() //取出文件夹对象
        var files = fs.readdirSync(target.path) //拿到文件名数组
        target.children = files.map(function(file) {
            var subPath = Path.resolve(target.path, file) //转化为绝对路径
            var stats = fs.statSync(subPath) //拿到文件信息对象
            var model = { //构造文件数据结构
                path: subPath,
                name: file,
                type: stats.isDirectory() ? 'directory' : 'file'
            }
            if (model.type === 'directory') {
                stack.push(model) //如果是文件夹,推入栈
            }
            return model //返回数据模型
        })
    }
    return result //返回整个数据结果
}

var cwd = process.cwd()
var tree = readdirs(cwd)
fs.writeFileSync(Path.join(cwd, 'tree.json'), JSON.stringify(tree)) //保存在tree.json中,去查看吧

循环策略中的popshiftpushunshift也可以互换以调整优先级,甚至用可以用splice方法更精细的控制stack数组。循环模式比递归模式更可控。

子场景2:异步模式

方案1:过程式 Promise

var fs = require('fs')
var Path = require('path')
//promise包装的fs.stat方法
var stat = function(path) {
    return new Promise(function(resolve, reject) {
        fs.stat(path, function(err, stats) {
            err ? reject(err) : resolve(stats)
        })
    })
}
//promise包装的fs.readdir方法
var readdir = function(path) {
    return new Promise(function(resolve, reject) {
        fs.readdir(path, function(err, files) {
            err ? reject(err) : resolve(files)
        })
    })
}
//promise包装的fs.writeFile
var writeFile = function(path, data) {
    return new Promise(function(resolve, reject) {
        fs.writeFile(path, JSON.stringify(data || ''), function(err) {
            err ? reject(err) : resolve
        })
    })
}

function readdirs(path) {
    return readdir(path) //异步读取文件夹
    .then(function(files) { //拿到文件名列表
        var promiseList = files.map(function(file) { //遍历列表
            var subPath = Path.resolve(path, file) //拼接为绝对路径
            return stat(subPath) //异步读取文件信息
            .then(function(stats) { //拿到文件信息
                //是文件夹类型的,继续读取目录,否则返回数据
                return stats.isDirectory() ?
                readdirs(subPath) : {
                    path: subPath,
                    name: file,
                    type: 'file'
                }
            })
        })
        return Promise.all(promiseList) //等待所有promise完成
    })
    .then(function(children) { //拿到包含所有数据的children数组
        return { //返回结果
            path: path,
            name: Path.basename(path),
            type: 'directory',
            children: children
        }
    })
}

var cwd = process.cwd()

readdirs(cwd)
.then(writeFile.bind(null, Path.join(cwd, 'tree.json'))) //保存在tree.json中,去查看吧
.catch(console.error.bind(console)) //出错了就输出错误日志查看原因

上面的函数都能工作,但都是一个个function的调用,显得太「过程式」;

能不能用面向对象的方式来写呢?

当然可以。

其实面向对象的写法,更清晰。

为了更加语义化,以及增显逼格。

我们用 ES6 的 class 来写这个树形结构类。

方案2:ES6-class + ES6-Promise

import fs from 'fs'
import {join, resolve, isAbsolute, basename, extname, dirname, sep} from 'path'

/**
 * 获取目录下的所有文件
 * @param {string} path
 * @return {promise} resolve files || reject error
 */
let readdir = (path) => {
    return new Promise((resolve, reject) => {
        fs.readdir(path, (err, files) => {
            err ? reject(err) : resolve(files)
        })
    })
}

/**
* 将data写入文件
* @param {string} path 路径
* @param {data} data
* @return {promise} resolve path || reject error
*/
let writeFile = (path, data) => {
    return new Promise((resolve, reject) => {
        fs.writeFile(path, data, (err) => {
            err ? reject(err) : resolve(path)
        })
    })
}

/**
* 获取文件属性
* @param {string} path
* @return {promise} resolve stats || reject error
*/
let stat = (path) => {
    return new Promise((resolve, reject) => {
        fs.stat(path, (err, stats) => {
            err ? reject(err) : resolve(stats)
        })
    })
}

/**
* 判断path是否存在
* @param {string} path 路径
* @return {promise} resolve exists
*/
let exists = (path) => {
    return new Promise((resolve) => fs.exists(path, resolve))
}


//文档类
class Document {
    constructor(path) {
        this.path = path
        this.name = basename(path)
    }
    //存在性判断
    exists() {
        return exists(this.path)
    }
    //异步获取文件信息
    stat() {
        return stat(this.path)
    }
    //输出基本数据
    json() {
        return JSON.stringify(this)
    }
    //将基本数据保存在path路径的文件中
    saveTo(path) {
        if (isAbsolute(path)) {
            return writeFile(path, this.json())
        }
        return writeFile(resolve(this.path, path), this.json())
    }
}

//文件类,继承自文档类
class File extends Document {
    constructor(path) {
        super(path) //必须先调用超类构造方法
        this.type = 'file' //type 为 file
        this.extname = extname(path) //新增扩展名
    }
    //写入数据
    write(data = '') {
        return writeFile(this.path, data)
    }
    //其他文件特有方法如 read unlink 等
}


//文件夹类,继承自文档类
class Directory extends Document {
    constructor(path) {
        super(path) //必须先调用超类构造方法
        this.type = 'directory'
    }
    //读取当前文件夹
    readdir() {
        return readdir(this.path) //读取目录
        .then((files) => { //拿到文件名列表
            let promiseList = files.map((file) => {
                let subPath = resolve(this.path, file) //拼接为绝对路径
                return stat(subPath) //获取文件信息
                .then((stats) => {
                    //根据文件信息,归类为Directory或File类型
                    return stats.isDirectory() ?
                    new Directory(subPath) :
                    new File(subPath)
                })
            })
            return Promise.all(promiseList)
        })
        .then((children) => { //拿到children数组
            this.children = children //保存children属性
            return children //返回children
        })
    }
    //深度读取文件目录
    readdirs() {
        return this.readdir() //读取当前文件夹
        .then((children) => { //拿到children
            let promiseList = []
            children.map((child) => {
                if (child instanceof Directory) { //是文件夹实例,继续深度读取文件目录
                    promiseList.push(child.readdirs())
                }
            })
            return Promise.all(promiseList) //等待所有子元素深度读取目录完毕
        })
        .then(() => this) //返回this
    }
    //其他文件夹特有方法如 addFile removeFile addDir remveDir 等
}

let cwd = process.cwd()

new Directory(cwd)
.readdirs()
.then((tree) => {
    tree.saveTo('tree.json') //让它自己保存在tree.json里
})
.catch(console.error.bind(console)) //输出错误日志

因为当前 JavaScript 引擎对 ES6 的支持度还不够,所以上述代码不能直接运行。可以通过以下两种方式来验证代码能不能跑起来。

第一种,先 npm install -g bable 全局安装 babel 工具,再以 babel-node tree.js的方式取代 node tree.js 来运行上述代码。

第二种,将上述代码黏贴到 https://babeljs.io/repl/,拿到编译为 ES5 的代码,将代码保存在 tree.js 文件中,以 ES5 的形式执行。

结语

以上就是我知道的一些用 JavaScript 处理树形结构的几种方法,希望看过后对你有帮助。

reactjs学习体验

reactjs学习体验

reactjs 是什么?

reactjs是来自facebook公司的用于构建用户界面的JavaScript库。

GitHub地址:https://github.com/facebook/react

reactjs的两个衍生项目也值得注意。

reactjs 真的将html/xml和js代码混杂在一起吗?

reactjs的jsx语法,让许多人感觉仿佛回到了原始社会。这么多年努力地让html\css\javascript三者分离,好不容易走到今天,reactjs却走回老路,让人难以接受。我也几次三番因为jsx而放弃了解reactjs。

目前体验下来,发觉那是误解。

reactjs比其他前端模板引擎更彻底的分离html与javascript。前端模板引擎,绝大多数基于html字符串;而reactjs不是。能接受前端模板引擎的人,也能接受jsx

jsx的实质是:用xml的语法写函数调用。它没有拼接html字符串,也不要求一定要使用jsx,手写函数调用,也是可以的。

在原生DOM中,用js构造dom的方式是这样的:

//要构造的dom:<a class="link" href="https://github.com/facebook/react">React<a>
var a = document.createElement('a')
a.setAttribute('class', 'link')
a.setAttribute('href', 'https://github.com/facebook/react')
a.appendChild(document.createTextNode('React'))

如你所见,它颇为繁琐,我们可以封装一下:

//第一个参数为node名
//第二个参数为一个对象,dom属性与事件都以键值对的形式书写
//第三个到第n个为子node,它们将按参数顺序出现,
//在这个例子中只有一个子元素,而且也是文本元素,所以可以直接书写,否则还得React.createElement一下
var a = React.createElement('a', {
    className: 'link',
    href: 'https://github.com/facebook/react'
}, 'React')

如上,从html语法到js构造dom,再到React.createElement的封装。

现在有个编译工具,可以让你用html语法来写React.createElement,部署上线前编译回来。你愿意吗?

不管你的答案是什么,但这就是jsx的一半真相。

正是由于jsx不是html字符串,所以有如下特点:

  • htmlclassfor属性在js里是保留字,所以jsx里要用别名classNamehtmlFor
  • 不能像下面那样操作htmlchecked属性
//在其他前端模板引擎中,可以这么做,因为是拼接字符串
var checkbox = <input type="checkbox" {this.props.selected ? 'checked' : ''} />

//但在jsx中,这是错误的,因为无法构成键值对,要有个key=value的格式,所以得这样
var checkbox = <input className="class是js的保留字" type="checkbox" checked={this.props.selected} />

//编译后:
var checkbox = React.createElement('input', {
    type: 'checkbox',
    className: 'class是js的保留字',
    checked: this.props.selected
})
  • 不能直接写并列的元素
//这样写是错误的
var MyComponent = React.createClass({
    render: function() {
        return <div>first div</div><div>second div</div>
    }
})
//因为编译后,return 两个函数调用,就算不报错,也只调用第一个函数,不合意图
var MyComponent = React.createClass({
    render: function() {
        return React.createElement('div', null, 'first div') React.createElement('div', null, 'second div')
    }
})

//所以有时难免要增加dom层级
var MyComponent = React.createClass({
    render: function() {
        return (
            <div>
                <div>first div</div>
                <div>second div</div>
            </div>
        )
    }
})

//编译后,合乎语法和编程意图了
var MyComponent = React.createClass({
    render: function() {
        return React.createElement('div', null, 
            React.createElement('div', null, 'first div'),
            React.createElement('div', null, 'second div'))
    }
})
  • jsx要求标签一定要闭合,html5中不强制要求闭合的,在jsx也都要闭合,以便识别
  • 封装的组件要用大写字母开头,以便跟html标签区分。
//不合规则
<tap />
//合乎规则
<Tap />

reactjs与web component

web component是下一代的前端标准,提供了shadow domtemplete元素、Imports自定义元素的功能。其中自定义元素提供了生命周期回调函数:

  • createdCallback: 创建时调用
  • attachedCallback: 添加到dom树时调用
  • detachedCallback: 从dom树衣橱时调用
  • attributeChangedCallback:属性改变时调用

reactjs中也有相似但更丰富的生命周期方法:

  • componentWillMount: 初始化渲染前调用
  • componentDidMount: 初始化渲染后调用
  • componentWillReceiveProps: 接受新props时调用
  • shouldComponentUpdate:接受新props或state时调用,返回值true/false决定是否更新视图
  • componentWillUpdate: 在接收到新的 props 或者 state 之前立刻调用。在初始化渲染的时候该方法不会被调用
  • componentDidUpdate:在组件的更新已经同步到 DOM 中之后立刻被调用
  • componentWillUnmount: 在组件从 DOM 中移除的时候立刻被调用

reactjsweb component的关系,在我个人看来:reactjs是纯js实现的一种component标准,它可以与DOM无关,甚至与Web无关。

reactjs中注册组件像这样:

//reactjs跟objective-c在方法命名上有些相似,使劲儿用全称,与传统js编程的缩写习惯相悖
var MyComponent = React.createClass({
    //每个组件必须有render方法
    render: function() {
        return <div className={this.props.className}>
            //jsx遇大括号就当作js表达式来看待
            //map返回的数组会自动展开
            {
                this.props.textList.map(function(text) {
                    return <p>text</p>
                })
            }
            </div>
    }
})

//像这样使用
var TestComponent = React.createClass({
    render: function() {
        return (
            <div>
                <MyComponent className="组件内部的this.props.className来自它被调用时传递的参数,就是我啦" textList={['组件的this.props.textList', '就是我啦', '用花括号包裹', '以便让jsx将我作为数组直接量的表达式来看待']} />
            </div>
        )
    }
})

//这里才是插入dom,用React.render方法
//第一个参数为React组件,第二个参数为DOM
React.render(
    <TestComponent />,
    document.body
)

总的来说,reactjs允许我们用React.createClass来拓展React.createElement的参数范畴。

  • 默认情况下,它接受原生html标签,所以web Component普及后,reactjs也不会被淘汰,无非是多了一些html标签罢了
  • React.createClass方法,可以提供新的html标签给React.createElement,创造了封装复杂dom结构、组件化的空间

reactjs 的虚拟dom

之前说了jsx的一半真相,另一半是,React.createElement并没有直接了当的用js构造dom,它构造了一种数据结构。

使用reactjs时,表面上我们在操作dom,其实是操作数据,reactjs通过自己的dom diff算法,对比前后的数据,找到diff差异点,按最小粒度更新视图。

正因如此,reactjsUI层才是可替换的,构造另一套从数据到视图的映射逻辑,就能应用在canvas乃至手机原生UI上。

reactjs 的单向数据流

reactjs组件内部的this.props对象,是组件实例的父级组件提供的,提供方式就像写html属性一样。

如此,父级复父级,数据可以从最顶层的组件实例,层层传递到最底层的组件中去,然而反过来却不行,这就是单向数据流的意思。

//最底层的todo
var Todo = React.createClass({
    render: function() {
        return (
            //只有html属性和data-*以及aria-*才会显示在dom中,其余的key或其他,是扩展性质的,便于向下级组件传递数据
            <li title={this.props.time} key={this.props.id}>
                <input type="checkbox" checked={this.props.completed} />
                <label>{this.props.title}</label>
            </li>
            )
    }
})
//todo的父级组件
var TodoList = React.createClass({
    render: function() {
        return ({
            <ul>{
                this.props.todos.map(function(todo) {
                    //形如ES6的属性展开式语法,等价于用key=value的形式一个个书写
                    return <Todo {...todo} />
                })
            }</ul>
        })
    }
})
//todoList的父级组件
var TodoApp = React.createClass({
    render: function() {
        return (
            <TodoList todos={this.props.todos} />
        )
    }
})

//模拟的todos数据
var data = [{
    id: new Date().getTime(),
    time: new Date().toLocaleString(),
    title: '第一个待办事项',
    completed: false
}, {
    id: new Date().getTime(),
    time: new Date().toLocaleString(),
    title: '第二个待办事项',
    completed: true
}]

//渲染TodoApp组件到#todo-app,数据从TodoApp传递到TodoList,从TodoList传递到Todo,在Todo中展开为一种DOM结构并注入数据,展示在前端页面中
React.render(
    <TodoApp todos={data} />,
    document.getElementById('todo-app')
)

结语

reactjs是有趣且富有生命力与表现力的javascript库,有其适用的场景,也有许多需要注意的事项与容易踩到的坑。

总体而言,学会它不会让人后悔(想想那些学angular1的同学吧)。

附上我用react+requirejs实现的todomvc,编码过程的不愉快主要在:jsx to js, modules to build,编译和打包的繁琐。

链接地址:

cannot find module react-lite/server

你好,我使用了recharts绘图,recharts引用了react-dom/server,react-lite是不是还不支持?是的话,有没有解决的建议?

在 JavaScript 中用匿名函数(箭头函数)写出递归的方法

前言

今天看 Mozilla 出品的 ES6 In Depth ,看到 Arrow functions(中文翻译),其中一段让人讶异。

Using arrows to pierce the dark heart of computer science

「使用箭头来刺穿计算机的黑暗心脏」

里面提到λ (lambda)表达式、阿隆佐·邱奇(Alonzo Church)、阿兰·图灵(Alan Turing),这些耳熟能详的名词原来与我们写 JavaScript 的人这么近,这激发了我极大的探索兴趣。

最后搜索到刘未鹏2006年的一篇文章《康托尔、哥德尔、图灵——永恒的金色对角线(rev#2)》,奇妙的从 ES2015 延伸到了计算机的起源以及现代数学史的开端。

我看到了它,却不敢相信它。——康托尔

计算机是数学家一次失败思考的产物。——无名氏

原来我们轻易写下的每一个匿名函数,里面都蕴涵简单而又玄妙的数学原理。

原来用匿名函数实现的递归,动用了如此深刻的数学法则。

希望每个前端工程师都能认真阅读刘未鹏的文章,理解 Y CombinatorJavaScript 实现,对这门正在越变越好的语言抱以更多的敬畏之情,写起 ES2015 来或许有更好的编程体验。

注:本文部分代码将用 ES2015 编写,要跑起来可能得先用Babel编译为 ES5。

正文

我们用递归的方式实现阶乘函数,并且从朴素思路出发,最后一步步抵达Y Combinator

首先,用最简单的命名函数递归方式,如下:

function factorial(n) {
    return n < 2 ? 1 : n * factorial(n - 1)
}

factorial(3) // => 6

第二种方式,用变量缓存匿名函数的值:

let fact = n => n < 2 ? 1 : n * fact(n - 1)

fact(4) // => 24

看,我们用匿名函数实现了递归,全剧终......

不,那只是 JS 引擎给我们的语法糖。实际上,所谓的「用 lambda 表达式写出递归」,不能在 lambda 定义完成之前直接引用自身。我们做如下假设:

let fact = n => n < 2 ? 1 : n * fact(n - 1) //抛出错误: lambda 表达式引用错误

在这个基础上,继续探索我们的话题。

如果 lambda 表达式不能直接在函数体内显示引用自身,那么我们就得隐式地调用自身;因为我们不是用循环来模拟递归,我们就是要让 lambda 表达式反复执行一段相同代码。

其中一个策略是,将 lambda 表达式作为参数之一传入其自身。(函数也是值)

//并不直接引用自身,引用 self 函数,调用时将自己传入即可
let fact = (self, n) => n < 2 ? 1 : n * self(self, n - 1)

//调用时,将自身作为第一个参数传入
fact(fact, 5) // => 120

OK,我们现在的确实现了具有递归效果的 lambda 表达式,但是,太难看了。没有人希望自己的阶乘函数有多余的参数,我们的目标是,fact(n)

为了达到参数纯净化目的,我们可以包裹一层工厂函数,封装肮脏的冗余传参行为。

//并不直接引用自身,引用 self 函数,调用时将自己传入即可
let fact = (self, n) => n < 2 ? 1 : n * self(self, n - 1)

//柯里化工厂函数,砍掉第一个参数
let factory = f => n => f(f, n)

//改造 fact
fact = factory(fact)

//参数纯净化
fact(6) // => 720

虽然现在我们达到了在调用时参数纯净化的目标,但仍有些不美。定义 fact 时,我们还在 self(self, n - 1), 方式不够直观,我们期望能用下面的方式代替。

//定义时就工厂化,生产出阶乘函数
let factory = self => n => n < 2 ? 1 : n * self(n - 1)

在函数被定义之后,我们才拿到其引用;也就是说,不可能在生产/创建一个函数时,把它自己传参进去。也就是说,对于上面的工厂函数 factory 而言,self === factory(self)永远不可能为真。不过,没关系。我们有软件工程里的黄金定律:

任何问题都可以通过增加一个间接层来解决。

既然无法让一个阶乘函数反复调用自身,那就让 factory 在需要时反复生产出虽然不是同一个,但效果等价的、新的阶乘函数。我们设想有以下特征的 Y 函数:

//定义时就工厂化,生产出阶乘函数
let factory = self => n => n < 2 ? 1 : n * self(n - 1)

//暂时不管 Y 函数的内部实现,假定它能够返回正确的阶乘函数。
let fact = Y(factory)

fact(6) // => 720

在知道Y函数的功能与行为后,我们再根据已知条件,把它构造出来。

首先,Y 函数一定返回阶乘函数,那么它的可能形式如下,

let Y = factory => {
    //Y 返回一个函数,其参数为 n,返回值为 n 的阶乘
    return n => { //求阶乘 }
}

其次,Y 一定调用了 factory 函数两次以上

let Y = factory => {
    let magic // 魔术函数,可以从 factory 中取出阶乘函数
    return n => factory(magic(factory))(n)
}

magic 函数从 factory 取出新的阶乘函数,作为参数又传入 factory,这样创建出来的阶乘函数,里面的 self 就是另一个阶乘函数。

到这里,我们只需要探究 magic 应该是什么代码形式。

let Y = factory => {
    //从 magic 的调用方式来看,它接受 factory 作为参数,返回一个新的阶乘函数
    let magic = factory => n => factory(magic(factory))(n)
    return n => factory(magic(factory))(n)
}

可惜,上面复用 magic 函数,也只是语法糖,我们不能在 magic 定义完成前显式引用它。

诶?

那么就再增加中间层,隐式引用呗。说做就做。

let Y = factory => {
    //就像之前做过的那样,把自己作为参数传入自己
    let magic = (self, factory) => n => factory(self(self, factory))(n)
    return n => factory(magic(magic, factory))(n)
}

//定义时就工厂化,生产出阶乘函数
let factory = self => n => n < 2 ? 1 : n * self(n - 1)

//测试我们构造出来的 Y 函数
let fact = Y(factory)

fact(7) // => 5040

惊!!,我们竟然成功了。虽然我们不知道 magic 魔术函数为什么是那样,但是,我们把它构造了出来。

同时,我们注意到,magicfactory 参数,好像没有存在的必要了,因为作用域内只存在唯一一个 factory

let Y = factory => {
    //砍掉多余的 factory 参数
    let magic = self => n => factory(self(self))(n)
    return n => factory(magic(magic))(n)
}

//定义时就工厂化,生产出阶乘函数
let factory = self => n => n < 2 ? 1 : n * self(n - 1)

//测试我们构造出来的 Y 函数
let fact = Y(factory)


console.log(fact(8)) // => 40320

神奇。magic 魔术函数果然很魔术,在外部 magic(magic) 自己调用自己, 在内部self(self),就实现了递归?

同时,我们又注意到一点,n => factory(magic(magic))(n)的形式跟n => factory(self(self))(n) 似乎一模一样,仅仅是 magicself 名字不同。

嗯?前者不就是把 magic 自身作为参数传递进自身的返回函数吗?

magic(magic) 是把自己传参进去,那么self === magic

原来 self(self) 自调用的函数,就是magic自身。

于是,我们得到:

let Y = factory => {
    //砍掉多余的 factory 参数
    let magic = self => n => factory(self(self))(n)
    //返回阶乘函数
    return magic(magic)
}

//定义时就工厂化,生产出阶乘函数
let factory = self => n => n < 2 ? 1 : n * self(n - 1)

//测试我们构造出来的 Y 函数
let fact = Y(factory)

console.log(fact(9)) // => 362880

看到最终的产物,让人惊呆了。这是什么黑魔法?

仔细一看,原来它就是 lambda 演算的 JavaScript 实现

// λ演算的写法
fix = λf.(λx.f(λv.x(x)(v)))(λx.f(λv.x(x)(v)))

// ES6的写法
const fix = f => (x => f(v => x(x)(v)))
               (x => f(v => x(x)(v)));

它不仅适用于阶乘函数的递归处理,任意递归工厂函数经过Y函数后,都能得到真正的递归函数。

let count = Y(self => x => {
    console.log(x++);
    x < 100 && self(x)
})

count(0) // 输出0 ~ 99

尾声

在这篇文章中,我们有意识地用到的特性只有一个:

函数也是值,可以作为参数传递

我们利用它,让一个函数自己调用自己,然后不断美化美化、简化简化,竟然就构造出了Y Combinator

然而:

  • 函数也是值,可传参中,反推出Y Combinator,不代表你有多厉害
  • 只是站在巨人的肩膀上
  • 背下函数也是值,可传参的定律,却不知道背后的原理就是λ演算
  • 就像还没学到微积分的高中生自己开创了微积分初步
  • 自比牛顿太幼稚,微积分原理与应用衍化成耳熟能详的说辞围绕着你
  • 没有这些弱启发,买菜还在数指头
  • 数学多美妙

warning react-lite.common.js

react-lite.common.js 998行 Side effects in initialization of unused variable nodeName
定义了一个未使用的变量。在构建的时候频繁警告

算法的两种心智模型:代数与几何

算法的两种心智模型:代数与几何

人类发展了两类反映世界的心智模型:语言和代数 vs 视觉和几何。分别对应「语义化」和「可视化」。

几乎所有人都具备使用和表达它们的能力。但不同的人对它们的依仗程度不同,甚至同一个人面对不同的问题时所采用的心智模型也可能不同。

下面的 merge sort 小测验,考量你对算法问题的处理方式是「代数型」、「几何型」还是「综合型」。

鉴定方式是,在看完对 merge sort 的两种类型的解读之后,自行体悟,报告出当你从头实现 merge sort 时,你倾向于用哪一种心智模型作为想象图景。

静态几何型

## 动态几何型

merge sort visualizer 点击链接,等待加载完成后,点击右上角导航条里的 run 按钮观看。

递归代数型

/**
 * 先写合并两个已排序的数组的函数
 */
function merge(left, right) {
    var result = []
    while (left.length > 0 || right.length > 0) {
        // left, right 数组任意一个长度为 0 时,拼接另一个数组即可(已排序) 
        if (left.length === 0) {
            return result.concat(right)
        } else if (right.length === 0) {
            return result.concat(left)
        }
        // 按从小到大排序,如果两数相等,一起 push 进去
        if (left[0] < right[0]) {
            result.push(left.shift())
        } else if (left[0] > right[0]) {
            result.push(right.shift())
        } else {
            result.push(left.shift(), right.shift())
        }
    }
    return result
}

/**
 * 对于未排序的数组,不断分解数组
 * 分出两个长度为 1 的数组后,它们就成了已排序的数组,调用 merge 函数
 * 递归地调用 mergeSort 把「已排序的小数组」合并成「已排序的大数组」
 */
function mergeSort(list) {
    if (list.length === 0 || list.length === 1) {
        return list.slice(0)
    }
    var halfLength = Math.floor(list.length / 2)
    var head =  list.slice(0, halfLength)
    var tail = list.slice(halfLength)
    var left = mergeSort(head)
    var right = mergeSort(tail)
    return merge(left, right)
}

mergeSort([38,27,43,3,9,82,10,123,5,23,5,7,23,4,7,25,23,42,362,34,234,2,342,34,234,2,36,7,8,3,4,234,12,4,234,2,35,235,1])

队列代数型

/**
 * 先写合并两个已排序的数组的函数
 */
function merge(left, right) {
    var result = []
    while (left.length > 0 || right.length > 0) {
        // left, right 数组任意一个长度为 0 时,拼接另一个数组即可(已排序) 
        if (left.length === 0) {
            return result.concat(right)
        } else if (right.length === 0) {
            return result.concat(left)
        }
        // 按从小到大排序,如果两数相等,一起 push 进去
        if (left[0] < right[0]) {
            result.push(left.shift())
        } else if (left[0] > right[0]) {
            result.push(right.shift())
        } else {
            result.push(left.shift(), right.shift())
        }
    }
    return result
}

function mergeSort(list) {
    if (list.length === 0) {
        return []
    } else if (list.length === 1) {
        return [list[0]]
    }

    // 复制一份 list,并把元素数组化,使之成为长度为 1 的「已排序数组」
    var queue = []
    for (var i = 0; i < list.length; i++) {
        queue[i] = [list[i]]
    }

    // 以队列形式,从头至尾,不断两两合并「已排序的数组」,直到队列里只剩一个「已排序数组」
    while (queue.length > 0) {
        if (queue.length === 1) {
            return queue[0]
        }
        var left = queue.shift()
        var right = queue.shift()
        // 把新的「已排序数组」放到队列的末尾
        queue.push(merge(left, right))
    }

    return queue[0]
}

mergeSort([38,27,43,3,9,82,10,123,5,23,5,7,23,4,7,25,23,42,362,34,234,2,342,34,234,2,36,7,8,3,4,234,12,4,234,2,35,235,1])

所以,你是什么类型呢?

IMVC(同构 MVC)的前端实践

IMVC(同构 MVC)的前端实践

导语

随着 Backbone 等老牌框架的逐渐衰退,前端 MVC 发展缓慢,有逐渐被 MVVM/Flux 所取代的趋势。

然而,纵观近几年的发展,可以发现一点,React/Vue 和 Redux/Vuex 是分别在 MVC 中的 View 层和 Model 层做了进一步发展。如果 MVC 中的 Controller 层也推进一步,将得到一种升级版的 MVC,我们称之为 IMVC(同构 MVC)。

IMVC 可以实现一份代码在服务端和浏览器端皆可运行,具备单页应用和多页应用的所有优势,并且可以这两种模式里通过配置项进行自由切换。配合 Node.js、Webpack、Babel 等基础设施,我们可以得到相比之前更加完善的一种前端架构。

目录

  • 1、同构的概念和意义
    • 1.1、isomorphic 是什么?
    • 1.2、isomorphic javascript
  • 2、同构的种类和层次
    • 2.1、同构的种类
    • 2.2、同构的层次
  • 3、同构的价值和作用
    • 3.1、同构的价值
    • 3.2、同构如何加快访问体验
    • 3.3、同构是未来的趋势
  • 4、同构的实现策略
  • 5、IMVC 架构
    • 5.1、IMVC 的目标
    • 5.2、IMVC 的技术选型
    • 5.3、为什么不直接用 React 全家桶?
    • 5.4、用 create-app 代替 react-router
      • 5.4.1、create-app 的同构理念
      • 5.4.2、create-app 的配置理念
      • 5.4.3、create-app 的服务端渲染
      • 5.4.4、create-app 的扁平化路由理念
      • 5.4.5、create-app 的目录结构
    • 5.5、controller 的基本模式
    • 5.6、redux 的简化版 relite
    • 5.7、Isomorphic-MVC 的工程化设施
      • 5.7.1、如何实现代码实时热更新?
      • 5.7.2、如何处理 CSS 按需加载?
      • 5.7.3、如何实现代码切割、按需加载?
      • 5.7.4、如何处理静态资源的版本管理?
      • 5.7.5、如何管理命令行任务?
  • 6、实践案例
  • 7、结语

1、同构的概念和意义

1.1、isomorphic 是什么?

isomorphic,读作[ˌaɪsə'mɔ:fɪk],意思是:同形的,同构的。

维基百科对它的描述是:同构是在数学对象之间定义的一类映射,它能揭示出在这些对象的属性或者操作之间存在的关系。若两个数学结构之间存在同构映射,那么这两个结构叫做是同构的。一般来说,如果忽略掉同构的对象的属性或操作的具体定义,单从结构上讲,同构的对象是完全等价的。

同构,也被化用在物理、化学以及计算机等其他领域。

1.2、isomorphic javascript

isomorphic javascript(同构 js),是指一份 js 代码,既然可以跑在浏览器端,也可以跑在服务端。

IMVC

图片来源:https://www.slideshare.net/spikebrehm/a-28174727

同构 js 的发展历史,比 progressive web app 还要早很多。2009 年, node.js 问世,给予我们前后端统一语言的想象;更进一步的,前后端公用一套代码,也不是不可能。

有一个网站 isomorphic.net,专门收集跟同构 js 相关的文章和项目。从里面的文章列表来看,早在 2011 年的时候,业界已经开始探讨同构 js,并认为这将是未来的趋势。

可惜的是,同构 js 其实并没有得到真正意义上的发展。因为,在 2011 年,node.js 和 ECMAScript 都不够成熟,我们并没有很好的基础设施,去满足同构的目标。

现在是 2017 年,情况已经有所不同。ECMAScript 2015 标准定案,提供了一个标准的模块规范,前后端通用。尽管目前 node.js 和浏览器都没有实现 ES2015 模块标准,但是我们有 Babel 和 Webpack 等工具,可以提前享用新的语言特性带来的便利。

2、同构的种类和层次

2.1、同构的种类

同构 js 有两个种类:「内容同构」和「形式同构」。

其中,「内容同构」指服务端和浏览器端执行的代码完全等价。比如:

function add(a, b) {
    return a + b
}

不管在服务端还是浏览器端,add 函数都是一样的。

而「形式同构」则不同,从原教旨主义的角度上看,它不是同构。因为,在浏览器端有一部分代码永远不会执行,而在服务端另一部分代码永远不会执行。比如:

function doSomething() {
  if (isServer) {
      // do something in server-side
  } else if (isClient) {
      // do something in client-side
  }
}

在 npm 里,有很多 package 标榜自己是同构的,用的方式就是「形式同构」。如果不作特殊处理,「形式同构」可能会增加浏览器端加载的 js 代码的体积。比如 React,它的 140+kb 的体积,是把只在服务端运行的代码也包含了进去。

2.2、同构的层次

同构不是一个布尔值,true 或者 false;同构是一个光谱形态,可以在很小范围里上实现同构,也可以在很大范围里实现同构。

  • function 层次:零碎的代码片断或者函数,支持同构。比如浏览器端和服务端都实现了 setTimeout 函数,比如 lodash/underscore 的工具函数都是同构的。

  • feature 层次:在这个层次里的同构代码,通常会承担一定的业务职能。比如 React 和 Vue 都借助 virtual-dom 实现了同构,它们是服务于 View 层的渲染;比如 Redux 和 Vuex 也是同构的,它们负责 Model 层的数据处理。

  • framework 层次:在框架层面实现同构,它可能包含了所有层次的同构,需要精心处理支持同构和不支持同构的两个部分,如何妥善地整合在一起。

我们今天所讨论的 isomorphic-mvc(简称 IMVC),是在 framework 层次上实现同构。

3、同构的价值和作用

3.1、同构的价值

同构 js,不仅仅有抽象上的美感,它还有很多实用价值。

  • SEO 友好:View 层在浏览器端和服务端都可以运行,意味着可以在服务端吐出 html,支持搜索引擎的抓取。

  • 加快访问体验:服务端渲染可以加快浏览器端的首次访问的渲染速度,而浏览器端渲染,可以加快用户交互时的反馈速度。

  • 代码的可维护性:同构可以减少语言切换的成本,减小代码的重复率,增加代码的可维护性。

不使用同构方案,也可以用别的办法实现前两个的目标,但是别的办法却难以同时满足三个目标。

3.2、同构如何加快访问体验

纯浏览器端渲染的问题在于,页面需要等待 js 加载完毕之后,才可见。

client-side renderging
IMVC

图片来源:https://www.slideshare.net/spikebrehm/a-28174727

服务端渲染可以加速首次访问的体验,在 js 加载之前,页面就渲染了首屏。但是,用户只对首次加载有耐心,如果操作过程中,频繁刷新页面,也会带给用户缓慢的感觉。

SERVER-SIDE RENDERING
IMVC

图片来源:https://www.slideshare.net/spikebrehm/a-28174727

同构渲染则可以得到两种好处,在首次加载时用服务端渲染,在交互过程中则采取浏览器端渲染。

3.3、同构是未来的趋势

从历史发展的角度看,同构确实是未来的一大趋势。

在 Web 开发的早期,采用的开发模式是:fat-server, thin-client

IMVC

图片来源:https://www.slideshare.net/spikebrehm/a-28174727

前端只是薄薄的一层,负责一些表单验证,DOM 操作和 JS 动画。在这个阶段,没有「前端工程师」这个工种,服务端开发顺便就把前端代码给写了。

在 Ajax 被发掘出来之后,Web 进入 2.0 时代,我们普遍推崇的模式是:thin-server, fat-client

IMVC

图片来源:https://www.slideshare.net/spikebrehm/a-28174727

越来越多的业务逻辑,从服务端迁移到前端。开始有「前后端分离」的做法,前端希望服务端只提供 restful 接口和数据持久化。

但是在这个阶段,做得不够彻底。前端并没有完全掌控渲染层,起码 html 骨架需要服务端渲染,以及前端实现不了服务端渲染。

为了解决上述问题,我们正在进入下一个阶段,这个阶段所采取的模式是:shared, fat-server, fat-client

IMVC

图片来源:https://www.slideshare.net/spikebrehm/a-28174727

通过 node.js 运行时,前端完全掌控渲染层,并且实现渲染层的同构。既不牺牲服务端渲染的价值,也不放弃浏览器端渲染的便利。

这就是未来的趋势。

4、同构的实现策略

要实现同构,首先要正视一点,全盘同构是没有意义的。为什么?

服务端和浏览器端毕竟是两个不同的平台和环境,它们专注于解决不同的问题,有自身的特点,全盘同构就抹杀了它们固有的差异,也就无法发挥它们各自的优势。

因而,我们只会在 client 和 server 有交集的部分实现同构。就是在服务端渲染 html 和在浏览器端复用 html 的整个过程里,实现同构。

我们采取的主要做法有两个:1)能够同构的代码,直接复用;2)无法同构的代码,封装成形式同构。

举几个例子。

获取 User-Agent 字符串。

IMVC

图片来源:https://www.slideshare.net/spikebrehm/a-28174727

我们可以在服务端用 req.get('user-agent') 模拟出 navigator 全局对象,也可以提供一个 getUserAgent 的方法或函数。

获取 Cookies。

IMVC

图片来源:https://www.slideshare.net/spikebrehm/a-28174727

Cookies 处理在我们的场景里,存在快捷通道,因为我们只专注首次渲染的同构,其它的操作可以放在浏览器端二次渲染的时候再处理。

Cookies 的主要用途发生在 ajax 请求的时候,在浏览器端 ajax 请求可以设置为自动带上 Cookies,所以只需要在服务端默默地在每个 ajax 请求头里补上 Cookies 即可。

Redirects 重定向处理

IMVC

图片来源:https://www.slideshare.net/spikebrehm/a-28174727

重定向的场景比较复杂,起码有三种情况:

  • 服务端 302 重定向: res.redirect(xxx)
  • 浏览器端 location 重定向:location.href = xxx location.replace(xxx)
  • 浏览器端 pushState 重定向:history.push(xxx)history.replace(xxx)

我们需要封装一个 redirect 函数,根据输入的 url 和环境信息,选择正确的重定向方式。

5、IMVC 架构

5.1、IMVC 的目标

IMVC 的目标是框架层面的同构,我们要求它必须实现以下功能

  • 用法简单,初学者也能快速上手
  • 只维护一套 ES2015+ 的代码
  • 既是单页应用,又是多页应用(SPA + SSR)
  • 可以部署到任意发布路径 (Basename/RootPath)
  • 一条命令启动完备的开发环境
  • 一条命令完成打包/部署过程

有些功能属于运行时的,有些功能则只服务于开发环境。JavaScript 虽然是一门解释型语言,但前端行业发展到现阶段,它的开发模式已经变得非常丰富,既可以用最朴素的方式,一个记事本加上一个浏览器,也可以用一个 IDE 加上一系列开发、测试和部署流程的支持。

5.2、IMVC 的技术选型

  • Router: create-app = history + path-to-regexp
  • View: React = renderToDOM || renderToString
  • Model: relite = redux-like library
  • Ajax: isomorphic-fetch

理论上,IMVC 是一种架构思路,它并不限定我们使用哪些技术栈。不过,要使 IMVC 落地,总得做出选择。上面就是我们当前选择的技术栈,将来它们可能升级或者替换为其它技术。

5.3、为什么不直接用 React 全家桶?

大家可能注意到,我们使用了许多 React 相关的技术,但却不是所谓的 React 全家桶,原因如下:

  • 目前的 React 全家桶其实是野生的,Facebook 并不用
  • React-Router 的理念难以满足要求
  • Redux 适用于大型应用,而我们的主要场景是中小型
  • 升级频繁导致学习成本过高,需封装一层更简洁的 API

目前的全家桶,只是社区里的一些热门库的组合罢了。Facebook 真正用的全家桶是 react|flux|relay|graphql,甚至他们并不用 React 做服务端渲染,用的是 PHP。

我们认为 React-Router 的理念在同构上是错误的。它忽视了一个重大事实:服务端是 Router 路由驱动的,把 Router 和作为 View 的 React 捆绑起来,View 已经实例化了,Router 怎么再加载 Controller 或者异步请求数据呢?

从函数式编程的角度看,React 推崇纯组件,需要隔离副作用,而 Router 则是副作用来源,将两者混合在一起,是一种污染。另外,Router 并不是 UI,却被写成 JSX 组件的形式,这也是有待商榷的。

所以,即便是当前最新版的 React-Router-v4,实现同构渲染时,做法也复杂而臃肿,服务端和浏览器端各有一个路由表和发 ajax 请求的逻辑。点击这里查看代码

至于 Redux,其作者也已在公开场合表示:「你可能不需要 Redux」。在引入 redux 时,我们得先反思一下引入的必要性。

毫无疑问,Redux 的模式是优秀的,结构清晰,易维护。然而同时它也是繁琐的,实现一个功能,你可能得跨文件夹地操作数个文件,才能完成。这些代价所带来的显著好处,要在 app 复杂到一定程度时,才能真正体会。其它模式里,app 复杂到一定程度后,就难以维护了;而 Redux 的可维护性还依然坚挺,这就是其价值所在。(值得一提的是,基于 redux 再封装一层简化的 API,我认为这很可能是错误的做法。Redux 的源码很简洁,意图也很明确,要简化固然也是可以的,但它为什么自己不去做?它是不是刻意这样设计呢?你的封装是否损害了它的设计目的呢?)

在使用 Redux 之前要考虑的是,我们 web-app 属于大型应用的范畴吗?

前端领域日新月异,框架和库的频繁升级让开发者应接不暇。我们需要根据自身的需求,进行二次封装,得到一组更简洁的 API,将部分复杂度隐藏起来,以降低学习成本。

5.4、用 create-app 代替 react-router

create-app 是我们为了同构而实现的一个 library,它由下面三部分组成:

  • history: react-router 依赖的底层库
  • path-to-regexp: expressjs 依赖的底层库
  • Controller:在 View(React) 层和 Model 层之外实现 Controller 层

create-app 复用 React-Router 的依赖 history.js,用以在浏览器端管理 history 状态;复用 expressjspath-to-regexp,用以从 path pattern 中解析参数。

我们认为,ReactRedux 分别对应 MVCViewModel,它们都是同构的,我们需要的是实现 Controller 层的同构。

5.4.1、create-app 的同构理念

IMVC

create-app 实现同构的方式是:

  • 输入 url,router 根据 url 的格式,匹配出对应的 controller 模块
  • 调用 module-loader 加载 controller 模块,拿到 Controller 类
  • View 和 Model 从属于 Controller 类的属性
  • new Controller(location, context) 得到 controller 实例
  • 调用 controller.init 方法,该方法必须返回 view 的实例
  • 调用 view-engine 将 view 的实例根据环境渲染成 html 或者 dom 或者 native-ui 等

上述过程在服务端和浏览器端都保持一致。

5.4.2、create-app 的配置理念

服务端和浏览器端加载模块的方式不同,服务端是同步加载,而浏览器端则是异步加载;它们的 view-engine 也是不同的。如何处理这些不一致?

答案是配置。

const app = createApp({
    type: 'createHistory',
    container: '#root',
    context: {
        isClient: true|false,
        isServer: false|true,
        ...injectFeatures
    },
    loader: webpackLoader|commonjsLoader,
    routes: routes,
    viewEngine: ReactDOM|ReactDOMServer,
})
app.start() || app.render(url, context)

服务端和浏览器端分别有自己的入口文件:client-entry.js 和 server.entry.js。我们只需提供不同的配置即可。

在服务端,加载 controller 模块的方式是 commonjsLoader;在浏览器端,加载 controller 模块的方式则为 webpackLoader。

在服务端和浏览器端,view-engine 也被配置为不同的 ReactDOM 和 ReactDOMServer。

每个 controller 实例,都有 context 参数,它也是来自配置。通过这种方式,我们可以在运行时注入不同的平台特性。这样既分割了代码,又实现了形式同构。

5.4.3、create-app 的服务端渲染

我们认为,简洁的,才是正确的。create-app 实现服务端渲染的代码如下:

const app = createApp(serverSettings)
router.get('*', async (req, res, next) => {
  try {
    const { content } = await app.render(req.url, serverContext)
    res.render('layout', { content })
  } catch(error) {
    next(error)
  }
})

没有多余的信息,也没有多余的代码,输入一个 url 和 context,返回具有真实数据 html 字符串。

5.4.4、create-app 的扁平化路由理念

React-Router 支持并鼓励嵌套路由,其价值存疑。它增加了代码的阅读成本,以及各个路由模块之间的关系与 UI(React 组件)的嵌套耦合在一起,并不灵活。

使用扁平化路由,可以使代码解耦,容易阅读,并且更为灵活。因为,UI 之间的复用,可以通过 React 组件的直接嵌套来实现。

基于路由嵌套关系来复用 UI,容易遇上一个尴尬场景:恰好只有一个页面不需要共享头部,而头部却不在它的控制范畴内。

// routes
export default [{
    path: '/demo',
    controller: require('./home/controller')
}, {
    path: '/demo/list',
    controller: require('./list/controller')
}, {
    path: '/demo/detail',
    controller: require('./detail/controller')
}]

如你所见,我们的 path 对应的并不是 component,而是 controller。通过新增 controller 层,我们可以实现在 view 层的 component 实例化之前,就借助 controller 获取首屏数据。

next.js 也是一个同构框架,它本质上是简化版的 IMVC,只不过它的 C 层非常薄,以至于直接挂在 View 组件的静态方法里。它的路由配置目前是基于 View 的文件名,其 Controller 层是 View.getInitialProps 静态方法,只服务于获取初始化 props。

这一层太薄了,它其实可以更为丰富,比如提供 fetch 方法,内置环境判断,支持 jsonp,支持 mock 数据,支持超时处理等特性,比如自动绑定 store 到 view,比如提供更为丰富的生命周期 pageWillLeave(页面将跳转到其他路径) 和 windowWillUnload (窗口即将关闭)等。

总而言之,副作用不可能被消灭,只能被隔离,如今 View 和 Model 都是 pure-function 和 immutabel-data 的无副作用模式,总得有角色承担处理副作用的职能。新的抽象层 Controller 应运而生。

5.4.5、create-app 的目录结构

├── src                       // 源代码目录                      
│   ├── app-demo                 // demo目录
│   ├── app-abcd                 // 项目 abcd 平台目录
│   │   ├── components          // 项目共享组件
│   │   ├── shared              // 项目共享方法
│   │        └── BaseController // 继承基类 Controller 的项目层 Controller   
│   │   ├── home                // 具体页面
│   │   │   ├── controller.js  // 控制器
│   │   │   ├── model.js       // 模型
│   │   │   └── view.js        // 视图
│   │   ├── *                   // 其他页面
│   │   └── routes.js           // abc 项目扁平化路由
│   ├── app-*                    // 其他项目
│   ├── components               // 全局共享组件
│   ├── shared                   // 全局共享文件
│   │   └── BaseController      // 基类 Controller   
│   ├── index.js                 // 全局 js 入口
│   └── routes.js                // 全局扁平化路由
├── static // 源码 build 的目标静态文件夹

如上所示,create-app 推崇的目录结构跟 redux 非常不同。它不是按照抽象的职能 actionCreator|actionType|reducers|middleware|container 来安排的,它是基于 page 页面来划分的,每个页面都有三个组成部分:controller,model 和 view。

用 routes 路由表,将 page 串起来。

create-app 采取了「整站 SPA」 的模式,全局只有一个入口文件,index.js。src 目录下的文件都所有项目共享的框架层代码,各个项目自身的业务代码则在 app-xxx 的文件夹下。

这种设计的目的是为了降低迁移成本,灵活切分和合并各个项目。

  • 当某个项目处于萌芽阶段,它可以依附在另一个项目的 git 仓库里,使用它现成的基础设施进行快速开发。
  • 当两个项目足够复杂,值得分割为两个项目时,它们可以分割为两个项目,各自将对方的文件夹整个删除即可。
  • 当两个项目要合并,将它们放到同一 git 仓库的不同 app-xxx 里即可。
  • 我们使用本地路由表 routes.js 和 nginx 配置协调 url 的访问规则

每个 page 的 controller.js,model.js 和 view.js 以及它们的私有依赖,将会被单独打包到一个文件,只有匹配 url 成功时,才会按需加载。保证多项目并存不会带来 js 体积的膨胀。

5.5、controller 的基本模式

我们新增了 controller 这个抽象层,它将承担连接 Model,View,History,LocalStorage,Server 等对象的职能。

Controller 被设计为 OOP 编程范式的一个 class,主要目的就是为了让它承受副作用,以便 View 和 Model 层保持函数式的纯粹。

Controller 的基本模式如下:

class MyController extends BaseController {
  requireLogin = true // 是否依赖登陆态,BaseController 里自动处理
  View = View // 视图
  initialState = { count: 0 } // model 初始状态initialState
  actions = actions // model 状态变化的函数集合 actions
  handleIncre = () => { // 事件处理器,自动收集起来,传递给 View 组件
    let { history, store, fetch, location, context } = this // 功能分层
    let { INCREMENT } = store.actions
    INCREMENT() // 调用 action,更新 state, view 随之自动更新
  }
  async shouldComponentCreate() {} // 在这里鉴权,return false
  async componentWillCreate() {} // 在这里 fetch 首屏数据
  componentDidMount() {} // 在这里 fetch 非首屏数据
  pageWillLeave() {} // 在这里执行路由跳转离开前的逻辑
  windowWillUnload() {} // 在这里执行页面关闭前的逻辑
}

我们将所有职能对象放到了 controller 的属性中,开发者只需提供相应的配置和定义,在丰富的生命周期里按需调用相关方法即可。

它的结构和模式跟 vue 和微信小程序有点相似。

5.6、redux 的简化版 relite

尽管作为中小型应用的架构,我们不使用 Redux,但是对于 Redux 中的优秀理念,还是可以吸收进来。

所以,我们实现了一个简化版的 redux,叫做 relite。

  • actionType, actionCreator, reducer 合并
  • 自动 bindActionCreators,内置异步 action 的支持
let EXEC_BY = (state, input) => {
    let value = parseFloat(input, 10)
    return isNaN(value) ? state : {
        ...state,
        count: state.count + value
    }
}
let EXEC_ASYNC = async (state, input) => {
    await delay(1000)
    return EXEC_BY(state, input)
}
let store = createStore(
  { EXEC_BY, EXEC_ASYNC },
  { count: 0 }
)

我们希望得到的是 redux 的两个核心:1)pure-function,2)immutable-data。

所以 action 函数被设计为纯函数,它的函数名就是 redux 的 action-type,它的函数体就是 redux 的 reducer,它的第一个参数是当前的 state,它的第二个参数是 redux 的 actionCreator 携带的数据。并且,relite 内置了 redux-promiseredux-thunk 的功能,开发者可以使用 async/await 语法,实现异步 action。

relite 也要求 state 尽可能是 immutable,并且可以通过额外的 recorder 插件,实现 time-travel 的功能。可以查看这个 demo 体验实际效果。

5.7、Isomorphic-MVC 的工程化设施

上面讲述了 IMVC 在运行时里的一些功能和特点,下面简单地描述一下 IMVC 的工程化设施。我们采用了:

  • node.js 运行时,npm 包管理
  • expressjs 服务端框架
  • babel 编译 ES2015+ 代码到 ES5
  • webpack 打包和压缩源码
  • standard.js 检查代码规范
  • prettier.js + git-hook 代码自动美化排版
  • mocha 单元测试

5.7.1、如何实现代码实时热更新?

  • 目标:一个命令启动开发环境,修改代码不需重启进程
  • 做法:一个 webpack 服务于 client,另一个 webpack 服务于 server
  • client: express + webpack-dev-middleware 在内存里编译
  • server: memory-fs + webpack + vm-module
  • 服务端的 webpack 编译到内存模拟的文件系统,再用 node.js 内置的虚拟机模块执行后得到新的模块

5.7.2、如何处理 CSS 按需加载?

  • 问题根源:浏览器只在 dom-ready 之前会等待 css 资源加载后再渲染页面
  • 问题描述:当单页跳转到另一个 url,css 资源还没加载完,页面显示成混乱布局
  • 处理办法:将 css 视为预加载的 ajax 数据,以 style 标签的形式按需引入
  • 优化策略:用 context 缓存预加载数据,避免重复加载

5.7.3、如何实现代码切割、按需加载?

  • 不使用 webpack-only 的语法 require.ensure
  • 在浏览器里 require 被编译为加载函数,异步加载
  • 在 node.js 里 require 是同步加载
// webpack.config.js
{
      test: /controller\.jsx?$/,
      loader: 'bundle-loader',
      query: {
        lazy: true,
        name: '[1]-[folder]',
        regExp: /[\/\\]app-([^\/\\]+)[\/\\]/.source
      },
      exclude: /node_modules/
}

5.7.4、如何处理静态资源的版本管理?

  • 以代码的 hash 为文件名,增量发布
  • 用 webpack.stats.plugin.js 生成静态资源表
  • express 使用 stats.json 的数据渲染页面
// webpack.config.js
output = {
    path: outputPath,
    filename: '[name]-[hash:6].js',
    chunkFilename: '[name]-[chunkhash:6].js'
}

5.7.5、如何管理命令行任务?

  • 使用 npm-scripts 在 package.json 里完成 git、webpack、test、prettier 等任务的串并联逻辑
  • npm start 启动完整的开发环境
  • npm run start:client 启动不带服务端渲染的开发环境
  • npm run build 启动自动化编译,构建与压缩部署的任务
  • npm run build:show-prod 用 webpack-bundle-analyzer 可视化查看编译结果

6、实践案例

7、结语

IMVC 经过实践和摸索,已被证明是一种有效的模式,它以较高的完成度实现了真正意义上的同构。不再局限于纸面上的理念描述,而是一个可以落地的方案,并且实际地提升了开发体验和效率。后续我们将继续往这个方向探索。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.