Mithril 组件

组件

结构

组件是对视图的一部分进行封装,以方便组织代码和重用。

任何具有 view 方法的 JavaScript 对象都是 Mithril 组件。组件可以用过 m() 函数调用:

var Example = {
    view: function() {
        return m("div", "Hello")
    }
}

m(Example)

// 等效 HTML
// <div>Hello</div>

把数据传递到组件

把一个 attrs 对象传入到 m() 函数的第二个参数,即可把参数传入到组件实例中:

m(Example, {name: "Floyd"})

在组件的视图和生命周期方法中可以通过 vnode.attrs 来访问数据:

var Example = {
    view: function (vnode) {
        return m("div", "Hello, " + vnode.attrs.name)
    }
}

注意:生命周期方法也可以通过 attrs 对象提供,所以在你自己的回调中应该避免使用生命周期方法名,因为它们会被 Mithril 调用。只有在你希望创建生命周期钩子的时候,才能在 attrs 对象中使用生命周期方法。

生命周期方法

组件都拥有相同的生命周期方法作为虚拟 DOM 节点,包括 oninitoncreateonupdateonbeforeremoveonremoveonbeforeupdate

var ComponentWithHooks = {
    oninit: function(vnode) {
        console.log("initialized")
    },
    oncreate: function(vnode) {
        console.log("DOM created")
    },
    onupdate: function(vnode) {
        console.log("DOM updated")
    },
    onbeforeremove: function(vnode) {
        console.log("exit animation can start")
        return new Promise(function(resolve) {
            // 动画完成后调用
            resolve()
        })
    },
    onremove: function(vnode) {
        console.log("removing DOM element")
    },
    onbeforeupdate: function(vnode, old) {
        return true
    },
    view: function(vnode) {
        return "hello"
    }
}

和其他类型的虚拟 DOM 节点一样,组件作为 vnode 使用时还额外定义了生命周期方法。

function initialize() {
    console.log("initialized as vnode")
}

m(ComponentWithHooks, {oninit: initialize})

vnode 中的生命周期方法不会覆盖组件的方法,反之亦然。组件的生命周期方法总是在 vnode 对应的生命周期方法之后运行。

注意 vnode 中你自己的回调函数名不要和生命周期方法名重复。

更多有关生命周期方法的信息,详见生命周期方法

状态

和所有的虚拟 DOM 节点一样,组件 vnode 也有状态。组件的状态对于支持面向对象的架构是有用的,可以用于封装(encapsulation )和分离关注点(separation of concerns)。

组件的状态有 3 种方式可以进行访问:初始化时、通过 vnode.state、通过组件方法中的 this 关键字。

初始化时

任何附加到组件对象上的属性,都会被复制到组件的实例中。利用这个特性可以进行简单的状态初始化。

在下面的例子中,dataComponentWithInitialState 组件的 state 对象的属性。

var ComponentWithInitialState = {
    data: "Initial content",
    view: function(vnode) {
        return m("div", vnode.state.data)
    }
}

m(ComponentWithInitialState)

// 等效 HTML
// <div>Initial content</div>

通过 vnode.state

状态也可以通过 vnode.state 属性访问,该属性可用于所有生命周期方法以及组件的 view 方法。

var ComponentWithDynamicState = {
    oninit: function(vnode) {
        vnode.state.data = vnode.attrs.text
    },
    view: function(vnode) {
        return m("div", vnode.state.data)
    }
}

m(ComponentWithDynamicState, {text: "Hello"})

// Equivalent HTML
// <div>Hello</div>

通过 this 关键字

状态也可以通过 this 关键字进行访问,该关键字可用于所有生命周期方法以及组件的 view 方法。

var ComponentUsingThis = {
    oninit: function(vnode) {
        this.data = vnode.attrs.text
    },
    view: function(vnode) {
        return m("div", this.data)
    }
}

m(ComponentUsingThis, {text: "Hello"})

// 等效 HTML
// <div>Hello</div>

注意,当使用 ES5 的函数时,嵌套的匿名函数中的 this 的值不是组件的实例。有两个方法来解决这个限制:使用 ES6 的箭头函数,或者 ES6 不可用时,使用 vnode.state

避免反面模式

尽管 Mithril 很灵活,但仍然有一些代码模式不推荐使用:

避免胖组件

通常来说,“胖”组件是指含自定义实例方法的组件。换句话说,你应该避免将函数附加到 vnode.statethis 上。一个逻辑只在一个组件实例上可用,但不能被其它组件重用,这种情况是非常罕见的。更常见的情况是一个逻辑可以被多个组件调用。

如果把逻辑放在数据层,而不是绑定到组件状态,那么重构代码会更加容易。

来看下这个胖组件:

// 避免这种用法
var Login = {
    username: "",
    password: "",
    setUsername: function(value) {
        this.username = value
    },
    setPassword: function(value) {
        this.password = value
    },
    canSubmit: function() {
        return this.username !== "" && this.password !== ""
    },
    login: function() {/*...*/},
    view: function() {
        return m(".login", [
            m("input[type=text]", {oninput: m.withAttr("value", this.setUsername.bind(this)), value: this.username}),
            m("input[type=password]", {oninput: m.withAttr("value", this.setPassword.bind(this)), value: this.password}),
            m("button", {disabled: !this.canSubmit(), onclick: this.login}, "Login"),
        ])
    }
}

通常,在大型应用中,除了上面的登录组件外,通常还会有注册和忘记密码组件。例如我们希望从登录页面跳转到注册或忘记密码页面时,能自动填写我们在登录页填写过的用户名,这时要把登录页的username共享到注册或忘记密码页面是很困难的。因为胖组件对状态进行了封装,使得从外部无法访问到组件内的状态。

我们来重构这个组件,把状态代码从组件内移动到数据层。这就像创建一个新模块一样简单:

// 建议这种用法
var Auth = {
    username: "",
    password: "",
    setUsername: function(value) {
        Auth.username = value
    },
    setPassword: function(value) {
        Auth.password = value
    },
    canSubmit: function() {
        return Auth.username !== "" && Auth.password !== ""
    },
    login: function() {/*...*/},
}

module.exports = Auth

然后,我们清理组件中的代码:

// 建议这种用法
var Auth = require("../models/Auth")

var Login = {
    view: function() {
        return m(".login", [
            m("input[type=text]", {oninput: m.withAttr("value", Auth.setUsername), value: Auth.username}),
            m("input[type=password]", {oninput: m.withAttr("value", Auth.setPassword), value: Auth.password}),
            m("button", {disabled: !Auth.canSubmit(), onclick: Auth.login}, "Login"),
        ])
    }
}

现在,Auth 模块成为了 auth 相关状态的来源,其他组件可以很容易地访问这些数据。

此外,不需要再使用 .bind 为组件的事件处理程序绑定状态的引用。

避免对接口造成限制

尽量保持组件的接口通用 - 直接使用 attrschildren - 除非组件需要指定的逻辑对输入进行操作。

在下面的例子中,button 受到了严重的限制:它不支持 onclick 之外的其他事件、不能自定义样式、只能使用纯文本作为子元素。

// 避免这种用法
var RestrictiveComponent = {
    view: function(vnode) {
        return m("button", {onclick: vnode.attrs.onclick}, [
            "Click to " + vnode.attrs.text
        ])
    }
}

如果需要的属性和普通的 DOM 属性一样,则最好允许将参数传递到组件的根节点。

// 推荐这种用法
var FlexibleComponent = {
    view: function(vnode) {
        return m("button", vnode.attrs, [
            "Click to ", vnode.children
        ])
    }
}

不要操作 children

通常组件需要定义多组子元素。例如,某个组件需要一个可以设置的 title 和 body,这时应该使用自定义的属性,而不是直接对 children 进行解析和操作。

// 避免这种用法
var Header = {
    view: function(vnode) {
        return m(".section", [
            m(".header", vnode.children[0]),
            m(".tagline", vnode.children[1]),
        ])
    }
}

m(Header, [
    m("h1", "My title"),
    m("h2", "Lorem ipsum"),
])

// 糟糕的用法
m(Header, [
    [
        m("h1", "My title"),
        m("small", "A small note"),
    ],
    m("h2", "Lorem ipsum"),
])

上面的组件直接对 children 进行操作,在不仔细阅读相关代码的情况下,很难知道 children[0]children[1] 代表什么,而使用自定义属性来命名参数则更加清晰易懂:

// 建议这种用法
var BetterHeader = {
    view: function(vnode) {
        return m(".section", [
            m(".header", vnode.attrs.title),
            m(".tagline", vnode.attrs.tagline),
        ])
    }
}

m(BetterHeader, {
    title: m("h1", "My title"),
    tagline: m("h2", "Lorem ipsum"),
})

// 清晰的用法
m(BetterHeader, {
    title: [
        m("h1", "My title"),
        m("small", "A small note"),
    ],
    tagline: m("h2", "Lorem ipsum"),
})

静态地定义组件,动态地调用它们

避免在视图中定义组件

如果把组件的定义放在了函数中,则每次调用函数时都将创建一个新的组件。当 diff 组件的 vnode 时,两个组件时不相等的,即使它们的代码完全一样。

// 避免这种用法
var ComponentFactory = function(greeting) {
    // 每次调用时都会创建一个新组件
    return {
        view: function() {
            return m("div", greeting)
        }
    }
}
m.render(document.body, m(ComponentFactory("hello")))
// 第二次调用时又重新创建了一个 div
m.render(document.body, m(ComponentFactory("hello")))

// 推荐这种用法
var Component = {
    view: function(vnode) {
        return m("div", vnode.attrs.greeting)
    }
}
m.render(document.body, m(Component, {greeting: "hello"}))
// 第二次调用时不会修改 DOM
m.render(document.body, m(Component, {greeting: "hello"}))

避免在视图外创建组件实例

如果在视图外创建组件实例,则重绘时会跳过对组件实例的差异检查。因此,组件实例应该始终在视图内创建:

// 避免这种用法
var Counter = {
    count: 0,
    view: function(vnode) {
        return m("div",
            m("p", "Count: " + vnode.state.count ),

            m("button", {
                onclick: function() {
                    vnode.state.count++
                }
            }, "Increase count")
        )
    }
}

var counter = m(Counter)

m.mount(document.body, {
    view: function(vnode) {
        return [
            m("h1", "My app"),
            counter
        ]
    }
})

在上面的示例中,点击组件的 Increase count 按钮会增加 count 的值,但视图不会更新,因为组件的 vnode 引用是同一个,渲染引擎会跳过对它们的差异检查。所以你应该始终在视图中调用组件,确保能创建新的 vnode:

// 建议这种用法
var Counter = {
    count: 0,
    view: function(vnode) {
        return m("div",
            m("p", "Count: " + vnode.state.count ),

            m("button", {
                onclick: function() {
                    vnode.state.count++
                }
            }, "Increase count")
        )
    }
}

m.mount(document.body, {
    view: function(vnode) {
        return [
            m("h1", "My app"),
            m(Counter)
        ]
    }
})