이 기사에서는 제가 Lazy Function Definition이라고 부르는 함수형 프로그래밍 디자인 패턴에 대해 설명합니다. 저는 이 패턴이 JavaScript에서 여러 번 유용하다는 것을 알았습니다. 특히 크로스 브라우저의 효율적인 라이브러리를 작성할 때 더욱 그렇습니다.
준비 질문
Date 객체를 반환하는 foo 함수를 작성하세요. 이 객체는 foo가 처음 호출될 때 시간을 절약해 줍니다.
방법 1: 고대의 기술
이 가장 간단한 솔루션은 전역 변수 t를 사용하여 Date 객체를 저장합니다. foo가 처음 호출되면 시간이 t에 저장됩니다. foo에 대한 후속 호출은 t에 저장된 값만 반환합니다.
var t; function foo() { if (t) { return t; } t = new Date(); return t; }
그런데 이 코드에는 두 가지 문제가 있습니다. 첫째, 변수 t는 중복된 전역 변수이며 foo 호출 사이에 변경될 수 있습니다. 둘째, foo가 호출될 때마다 조건을 평가해야 하기 때문에 코드는 호출 시 효율성을 위해 최적화되지 않습니다. 이 예에서는 조건을 평가하는 것이 비효율적이지 않은 것처럼 보이지만 실제 실제 예에서는 if-else-else-... 구조와 같이 조건 평가에 비용이 매우 많이 드는 경우가 많습니다.
방법 2: 모듈 패턴
첫 번째 방법의 단점을 Cornford와 Crockford의 모듈 패턴을 통해 보완할 수 있습니다. foo 내의 코드만 액세스할 수 있도록 클로저를 사용하여 전역 변수 t를 숨깁니다.
var foo = (function() { var t; return function() { if (t) { return t; } t = new Date(); return t; } })();
그러나 foo에 대한 각 호출에는 여전히 평가 조건이 필요하기 때문에 이는 여전히 호출 효율성을 최적화하지 않습니다.
모듈 패턴은 강력한 도구이지만, 이 상황에서는 엉뚱한 곳에 사용되고 있다고 굳게 믿습니다.
방법 3: 객체로서의 함수
자바스크립트 함수도 객체이므로 속성을 가질 수 있습니다. 이를 기반으로 모듈 패턴과 품질이 유사한 솔루션을 구현할 수 있습니다.
function foo() { if (foo.t) { return foo.t; } foo.t = new Date(); return foo.t; }
어떤 경우에는 속성이 있는 함수 객체가 더 깔끔한 솔루션을 생성할 수 있습니다. 내 생각에는 이 접근 방식이 패턴 모듈 접근 방식보다 개념적으로 더 간단하다고 생각합니다.
이 솔루션은 첫 번째 방법에서 전역 변수 t를 방지하지만 foo에 대한 각 호출로 인해 발생하는 조건부 평가를 여전히 해결할 수 없습니다.
방법 4: 게으른 함수 정의
이제 이 글을 읽고 있는 이유는 다음과 같습니다.
var foo = function() { var t = new Date(); foo = function() { return t; }; return foo(); };
当foo首次调用,我们实例化一个新的Date对象并重置 foo到一个新的函数上,它在其闭包内包含Date对象。在首次调用结束之前,foo的新函数值也已调用并提供返回值。
接下来的foo调用都只会简单地返回t保留在其闭包内的值。这是非常快的查找,尤其是,如果之前那些例子的条件非常多和复杂的话,就会显得很高效。
弄清这种模式的另一种途径是,外围(outer)函数对foo的首次调用是一个保证(promise)。它保证了首次调用会重定义foo为一个非常有用的函数。笼统地说,术语“保证” 来自于Scheme的惰性求值机制(lazy evaluation mechanism)。每一位JavaScript程序员真的都应该 学习Scheme ,因为它有很多函数式编程相关的东西,而这些东西会出现在JavaScript中。
确定页面滚动距离
编写跨浏览器的JavaScript, 经常会把不同的浏览器特定的算法包裹在一个独立的JavaScript函数中。这就可以通过隐藏浏览器差异来标准化浏览器API,并让构建和维护复杂的页面特性的JavaScript更容易。当包裹函数被调用,就会执行恰当的浏览器特定的算法。
在拖放库中,经常需要使用由鼠标事件提供的光标位置信息。鼠标事件给予的光标坐标相对于浏览器窗口而不是页面。加上页面滚动距离鼠标的窗口坐标的距离即可得到鼠标相对于页面的坐标。所以我们需要一个反馈页面滚动的函数。演示起见,这个例子定义了一个函数getScrollY。因为拖放库在拖拽期间会持续运行,我们的getScrollY必须尽可能高效。
不过却有四种不同的浏览器特定的页面滚动反馈算法。Richard Cornford在他的feature detection article 文章中提到这些算法。最大的陷阱在于这四种页面滚动反馈算法其中之一使用了 document.body. JavaScript库通常会在HTML文档的93f0f5c25f18dab9d176bd4f6de5d30e加载,与此同时docment.body并不存在。所以在库载入的时候,我们并不能使用特性检查(feature detection)来确定使用哪种算法。
考虑到这些问题,大部分JavaScript库会选择以下两种方法中的一种。第一个选择是使用浏览器嗅探navigator.userAgent,为该浏览器创建高效、简洁的getScrollY. 第二个更好些的选择是getScrollY在每一次调用时都使用特性检查来决定合适的算法。但是第二个选择并不高效。
好消息是拖放库中的getScrollY只会在用户与页面的元素交互时才会用到。如果元素业已出现在页面中,那么document.body也会同时存在。getScrollY的首次调用,我们可以使用惰性函数定义模式结合特性检查来创建高效的getScrollY.
var getScrollY = function() { if (typeof window.pageYOffset == 'number') { getScrollY = function() { return window.pageYOffset; }; } else if ((typeof document.compatMode == 'string') && (document.compatMode.indexOf('CSS') >= 0) && (document.documentElement) && (typeof document.documentElement.scrollTop == 'number')) { getScrollY = function() { return document.documentElement.scrollTop; }; } else if ((document.body) && (typeof document.body.scrollTop == 'number')) { getScrollY = function() { return document.body.scrollTop; } } else { getScrollY = function() { return NaN; }; } return getScrollY(); }
总结
惰性函数定义模式让我可以编写一些紧凑、健壮、高效的代码。用到这个模式的每一次,我都会抽空赞叹JavaScript的函数式编程能力。
JavaScript同时支持函数式和面向对象便程。市面上有很多重点着墨于面向对象设计模式的书都可以应用到JavaScript编程中。不过却没有多少书涉及函数式设计模式的例子。对于JavaScript社区来说,还需要很长时间来积累良好的函数式模式。
更新:
这个模式虽然有趣,但由于大量使用闭包,可能会由于内存管理的不善而导致性能问题。来自 FCKeditor 的FredCK改进了getScrollY,既使用了这种模式,也避免了闭包:
var getScrollY = function() { if (typeof window.pageYOffset == 'number') return (getScrollY = getScrollY.case1)(); var compatMode = document.compatMode; var documentElement = document.documentElement; if ((typeof compatMode == 'string') && (compatMode.indexOf('CSS') >= 0) && (documentElement) && (typeof documentElement.scrollTop == 'number')) return (getScrollY = getScrollY.case2)(); var body = document.body ; if ((body) && (typeof body.scrollTop == 'number')) return (getScrollY = getScrollY.case3)(); return (getScrollY = getScrollY.case4)(); }; getScrollY.case1 = function() { return window.pageYOffset; }; getScrollY.case2 = function() { return documentElement.scrollTop; }; getScrollY.case3 = function() { return body.scrollTop; }; getScrollY.case4 = function() { return NaN; };