命令式、声明式、函数式、面向对象、控制反转之华山论剑(下)

原文链接: https://juejin.im/post/5950b1015188250d7952799a

命令式、声明式、函数式、面向对象、控制反转之华山论剑(下)

本文的所有例子均在当前目录下的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']
  }
}复制代码

分析代码也非常简单,这里卖个关子,各位感兴趣的话,可以自己实现。

总结

笔者不爱写总结,就酱。

你可能感兴趣的:(命令式、声明式、函数式、面向对象、控制反转之华山论剑(下))