命令式、声明式、函数式、面向对象、控制反转之华山论剑(下)
本文的所有例子均在当前目录下的html文件中,出于对慵懒同学的保护,双击即可运行
命令式与声明式的实际例子
上文说了一堆理论,部分同学已经出现了大海的感觉。下面我们通过一个实际的例子(本例子根据真实场景改编),介绍下命令式与声明式的区别与函数式编程中的控制反转。
我们要编写一个网页版的IDE,IDE依赖Layout模块和Store模块,Layout依赖Menu等模块。如果使用命令式编程,IDE代码如下:
function IDE(options){
// 处理属性
...
// 初始化组件
this.layout = Layout.init(options.layout)
this.store = Store.init(options.store)
// 渲染组件
return Layout.render(this.layout)
}复制代码
作为一个初级程序员,这样完全能实现业务需求。但是缺点显而易见,逻辑与数据完全耦合在IDE内部,只要业务有变动,我们就需要频繁修改组件的代码。这样非常不利于代码维护。
我们来把代码改造下。把逻辑封装在配置声明中。
// 声明式
const options = {
title: 'ide',
args: 'ide args',
dependency: {
Layout: {
title: 'layout',
args: 'layout args',
dependency: {
Menu: {
title: 'menus',
args: 'menus args',
}
},
},
Store: {
title: 'store',
args: 'store args',
},
},
}复制代码
我们把组件的依赖放到配置文件中的dependency字段,依赖关系清晰可见。而不用像过程式编程中,我们需要阅读源码,才能看懂组件之间的依赖于调用关系。
使用的地方更加简洁
const render = Frame().render
const dispatchOptions = options => ({ render: () => render(options) })
const Menu = dispatchOptions
const Store = dispatchOptions
const Layout = dispatchOptions
const Ide = dispatchOptions复制代码
我们看到,我们仅仅是拿到了配置,调用了render函数而已。
而框架代码处理代码,也很简单
const keys = Object.keys
const json2Str = 'stringify'
const drawHandleName = 'render'
const Util = {
resolve: (string, handle) => eval(string)[handle](),
tranlateJSON: (object, type) => JSON[type](object),
concat: (items, sep) => Array.isArray(items) ? items.join(sep) : items,
}
const { resolve, tranlateJSON, concat } = Util
const Frame = () => {
const renderModule = (children, funName) => {
const finalFunArgs = tranlateJSON(children[funName], json2Str)
return resolve(`${funName}(${finalFunArgs})`, drawHandleName)
}
const analyzeDependency = (dependency = {} ) =>
concat(keys(dependency).map(childrenName => renderModule(dependency, childrenName)), '')
const render = ({ title, options, dependency }) =>`
标题:${title}-参数:${options}
${analyzeDependency(dependency)}` return { render, } }复制代码
最终对外只暴露了一个render函数,这个函数拿到组件的配置与依赖,分别做输入与处理依赖。
analyzeDependency拿到每个组件依赖,循环依赖,然后调用渲染模块函数。
渲染模块拿到依赖名字(如Layout)与参数args(如{ title: 'layout', options: 'layout options', dependency: ... }), 运行Layout().render(args)。把运行结果返回给analyzeDependency再返回给render。
在实际生产中,每个组件的参数分析函数可能并不相同,这个小例子里并没有处理。当然,这个并不难处理。
OK,这就是声明式编程,业务需求并不关心Frame是如何处理,也就是说,我们无需关心计算机如何处理。每当有依赖变化时候,我们只需要处理声明的配置与每个组件的render即可,实现起来非常简单。
控制反转-例子
当然,虽然上个例子做到了声明式编程,但是依赖仍然耦合在配置声明中,缺点如下:
- 不利于做配置声明(声明耦合,最后的声明非常长,且难以分析)。
- 不利于做单元测试(因为我们必须依赖于最外层的IDE以及所有父级的组件和所有的配置声明)。
- 不利于做逻辑分离,看似没有任何逻辑依赖,但是每个组件render函数却显式调用了render函数。
- 不利于做依赖分析,每个组件的配置只有在运行时才能通过options传入,在运行组件前,我们并不知道组件依赖哪些组件。(在不分析配置声明的情况下)
现在,我们把刚才的代码做一下改动,把配置声明挪到每个组件内,把控制权完全交给程序,让程序根据组件配置,动态生成逻辑。
const render = ({ title, options }, children) =>
`
标题:${title}-参数:${options}
${children} ` const Menu = () => { return { dependency: {}, render, } } const Store = () => { return { dependency: {}, render, } } const Layout = () => { return { dependency: { Menu: { title: 'menus', options: 'menus options', } }, render, } } const Ide = () => { return { dependency: { Layout: { title: 'layout', options: 'layout options', }, Store: { title: 'store', options: 'store options', }, }, render, } } const options = { dependency: { Ide: { title: 'ide', options: 'ide options', }, }, }复制代码
我们看到,配置声明移到了各个组件内部,而每个组件的配置自己实现了render函数,render函数不在依赖于框架,而是自己实现。每个render拿到子组件可以随意处理。而框架要做的,就是把分析出每个组件要依赖的子组件,并且完成子组件的渲染,然后传递给父组件的render参数。
接下来,我们看一下框架的代码
const { assign, keys } = Object
const dependencyField = 'dependency'
const iocFrame = (options, modules) => {
const { dependency } = options
const concat = (items, sep) => Array.isArray(items) ? items.join(sep) : items
// 依赖汇总
const collectDependency = (depend, modules) => {
if (!depend || !keys(depend).length) return []
return keys(depend).map((moduleId) => {
const { dependency, render } = modules[moduleId]()
return assign(depend[moduleId], { render, dependency: collectDependency(dependency, modules) })
})
}
const dependencyTree = collectDependency(dependency, modules)
// 分析依赖
const analyzeDependency = (options, childrenName) => {
if (Array.isArray(options[childrenName])) {
const renderArgs = concat(options[childrenName].map((child, index) =>
analyzeDependency(child, childrenName)), '')
return options.render(options, renderArgs)
}
return options.render(options)
}
return {
render: () => analyzeDependency(dependencyTree[0], dependencyField),
}
}复制代码
collectDependency函数递归查找所有的函数依赖,并且拼装成一颗完整的依赖树。analyzeDependency函数拿到依赖树中的配置声明,递归依次执行render函数。
注意renderArgs这个变量,我们递归分析出依赖的render函数返回值。把这个返回值,也就是组件所依赖所有子组件的render函数全部运行一遍,每次运行render的时候,我们查找依赖,如果有依赖,把依赖当做参数在去递归查找,直到没有找到依赖为止。然后汇总所有render的返回结果,当做当前组件的子组件传递给当前组件的render函数。
有人说我们不是做了两次递归,一次分析依赖,一次处理依赖,为什么不在同一次递归内完成,这样性能会有所增加,请注意,我们只在静态初始化时候分析依赖,而在组件渲染时候才去处理依赖。而分析依赖,拿到完整的依赖列表,能让我们处理更多的事情。
这样我们仅仅声明了配置(当然配置里也可以有某些行为,就是这里的render函数),通过框架来处理如何调用行为,做到了动态生成逻辑。
控制反转
如果我们把函数调用完全声明在配置声明中,那是不是就可以不用写任何逻辑,仅仅依靠声明就可以完全控制逻辑的走向?没错,这就是控制反转,把控制权完全交给底层框架(也可以认为是计算机),我们仅仅需要声明函数即可,而函数如何调用,何时调用,我们需要框架来约定。至于框架如何约定,那完全取决于业务需求,对于同样的业务,做好业务分析,找出常见(依赖)的变化,把不变的封装成框架,把变化的留作配置声明。
举个例子:
function a(opts){
if (cond1)
b()
opts.forEach(opt=> c(opt))
if (cond2)
d()
...
}复制代码
这种代码通过控制反转,如何声明呢?
function a(opts) {
return {
logic: {
if_1: { condition: cond1 , handle: b, },
for: { source: opts, handle: opt => c(opt), },
if_2: { condition: cond2, handle: d, },
},
dependency : ['b', 'c', 'd']
}
}复制代码
分析代码也非常简单,这里卖个关子,各位感兴趣的话,可以自己实现。
总结
笔者不爱写总结,就酱。