组件
结构
组件是一种用于封装视图各个部分的机制,从而使代码更易于组织和复用。
任何包含 view
方法的 JavaScript 对象都是 Mithril.js 组件。组件可以通过 m()
工具函数使用:
// 定义你的组件
var Example = {
view: function (vnode) {
return m('div', 'Hello');
},
};
// 使用你的组件
m(Example);
// 等效的 HTML
// <div>Hello</div>
生命周期方法
组件可以具有与虚拟 DOM 节点相同的生命周期方法。请注意,vnode
会作为参数传递给每个生命周期方法,以及 view
(onbeforeupdate
还会额外传递_先前_的 vnode):
var ComponentWithHooks = {
oninit: function (vnode) {
console.log('initialized');
},
oncreate: function (vnode) {
console.log('DOM created');
},
onbeforeupdate: function (newVnode, oldVnode) {
return true;
},
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');
},
view: function (vnode) {
return 'hello';
},
};
与其他类型的虚拟 DOM 节点一样,组件在作为 vnode 类型使用时,可以定义额外的生命周期方法。
function initialize(vnode) {
console.log('initialized as vnode');
}
m(ComponentWithHooks, { oninit: initialize });
vnode 中的生命周期方法不会覆盖组件方法,反之亦然。组件生命周期方法始终在 vnode 的相应方法之后运行。
请注意,不要在 vnode 中使用与生命周期方法名称相同的回调函数名称。
要了解有关生命周期方法的更多信息,请参阅生命周期方法页面。
将数据传递给组件
可以通过在 hyperscript 函数中将 attrs
对象作为第二个参数传递给组件实例来传递数据:
m(Example, { name: 'Floyd' });
此数据可以在组件的视图或生命周期方法中通过 vnode.attrs
访问:
var Example = {
view: function (vnode) {
return m('div', 'Hello, ' + vnode.attrs.name);
},
};
注意:生命周期方法也可以在 attrs
对象中定义,因此你应该避免使用它们的名称作为你自己的回调,因为它们也会被 Mithril.js 本身调用。仅当你明确希望将它们用作生命周期方法时,才在 attrs
中使用它们。
状态
与所有虚拟 DOM 节点一样,组件 vnode 可以具有状态。组件状态对于支持面向对象架构、封装和关注点分离非常有用。
请注意,与许多其他框架不同,改变组件状态_不会_触发重绘或 DOM 更新。相反,当事件处理程序触发、当 m.request 发出的 HTTP 请求完成或当浏览器导航到不同的路由时,会执行重绘。Mithril.js 的组件状态机制只是为了方便应用程序使用。
如果发生的状态更改不是由于上述任何条件(例如,在 setTimeout
之后),则可以使用 m.redraw()
手动触发重绘。
闭包组件状态
在上面的示例中,每个组件都定义为一个 POJO(Plain Old JavaScript Object,普通 JavaScript 对象),Mithril.js 在内部将其用作该组件实例的原型。可以使用 POJO 使用组件状态(我们将在下面讨论),但这不是最干净或最简单的方法。为此,我们将使用**闭包组件**,它只是一个包装函数,它_返回_一个 POJO 组件实例,该实例反过来携带其自己的、封闭的作用域。
使用闭包组件时,状态可以通过在外部函数中声明变量来简单维护:
function ComponentWithState(initialVnode) {
// 组件状态变量,每个实例都是唯一的
var count = 0;
// POJO 组件实例:任何具有
// 返回 vnode 的 view 函数的对象
return {
oninit: function (vnode) {
console.log('init a closure component');
},
view: function (vnode) {
return m(
'div',
m('p', 'Count: ' + count),
m(
'button',
{
onclick: function () {
count += 1;
},
},
'Increment count'
)
);
},
};
}
闭包内声明的任何函数都可以访问这些状态变量。
function ComponentWithState(initialVnode) {
var count = 0;
function increment() {
count += 1;
}
function decrement() {
count -= 1;
}
return {
view: function (vnode) {
return m(
'div',
m('p', 'Count: ' + count),
m(
'button',
{
onclick: increment,
},
'Increment'
),
m(
'button',
{
onclick: decrement,
},
'Decrement'
)
);
},
};
}
闭包组件的使用方式与 POJO 相同,例如 m(ComponentWithState, { passedData: ... })
。
闭包组件的一个很大的优点是,在附加事件处理程序回调时,我们无需担心绑定 this
。事实上,this
从未使用过,我们也不必考虑 this
上下文的歧义。
POJO 组件状态
通常建议使用闭包来管理组件状态。但是,如果你有理由在 POJO 中管理状态,则可以通过三种方式访问组件的状态:作为初始化时的蓝图、通过 vnode.state
以及通过组件方法中的 this
关键字。
初始化时
对于 POJO 组件,组件对象是每个组件实例的原型,因此在组件对象上定义的任何属性都可以作为 vnode.state
的属性访问。这允许简单的“蓝图”状态初始化。
在下面的示例中,data
成为 ComponentWithInitialState
组件的 vnode.state
对象的一个属性。
var ComponentWithInitialState = {
data: 'Initial content',
view: function (vnode) {
return m('div', vnode.state.data);
},
};
m(ComponentWithInitialState);
// Equivalent 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' });
// Equivalent HTML
// <div>Hello</div>
需要注意的是,使用 ES5 函数时,嵌套匿名函数中的 this
值不是组件实例。有两种推荐的方法可以解决此 JavaScript 限制:使用箭头函数,或者如果不支持箭头函数,则使用 vnode.state
。
类
如果它适合你的需求(例如在面向对象项目中),也可以使用类编写组件:
class ClassComponent {
constructor(vnode) {
this.kind = 'class component';
}
view() {
return m('div', `Hello from a ${this.kind}`);
}
oncreate() {
console.log(`A ${this.kind} was created`);
}
}
类组件必须定义 view()
方法,该方法可通过 .prototype.view
检测到,以获取要渲染的树。
它们可以以与常规组件相同的方式使用。
// 示例:通过 m.render
m.render(document.body, m(ClassComponent));
// 示例:通过 m.mount
m.mount(document.body, ClassComponent);
// 示例:通过 m.route
m.route(document.body, '/', {
'/': ClassComponent,
});
// 示例:组件组合
class AnotherClassComponent {
view() {
return m('main', [m(ClassComponent)]);
}
}
类组件状态
使用类,可以通过类实例属性和方法来管理状态,并通过 this
访问:
class ComponentWithState {
constructor(vnode) {
this.count = 0;
}
increment() {
this.count += 1;
}
decrement() {
this.count -= 1;
}
view() {
return m(
'div',
m('p', 'Count: ', this.count),
m(
'button',
{
onclick: () => {
this.increment();
},
},
'Increment'
),
m(
'button',
{
onclick: () => {
this.decrement();
},
},
'Decrement'
)
);
}
}
请注意,我们必须对事件处理程序回调使用箭头函数,以便可以正确引用 this
上下文。
混合组件类型
组件可以自由混合。类组件可以将闭包或 POJO 组件作为子组件,等等...
特殊属性
Mithril.js 对几个属性键赋予了特殊的语义,因此通常应避免在普通组件属性中使用它们。
- 生命周期方法:
oninit
、oncreate
、onbeforeupdate
、onupdate
、onbeforeremove
和onremove
key
,用于跟踪键控片段中的标识tag
,用于区分 vnode 与普通属性对象和其他非 vnode 对象。
避免反模式
虽然 Mithril.js 很灵活,但有些代码模式是不推荐的:
避免胖组件
一般来说,“胖”组件是指具有自定义实例方法的组件。换句话说,你应该避免将函数附加到 vnode.state
或 this
。很少有逻辑在逻辑上适合组件实例方法,并且不能被其他组件重用。更常见的情况是,不同的组件可能需要在以后使用该逻辑。
如果该逻辑位于数据层中,而不是与组件状态相关联,则更容易重构代码。
考虑这个胖组件:
// views/Login.js
// 避免
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: function (e) {
this.setUsername(e.target.value);
},
value: this.username,
}),
m('input[type=password]', {
oninput: function (e) {
this.setPassword(e.target.value);
},
value: this.password,
}),
m(
'button',
{ disabled: !this.canSubmit(), onclick: this.login },
'Login'
),
]);
},
};
通常,在更大的应用程序的上下文中,像上面这样的登录组件与用户注册和密码恢复的组件一起存在。想象一下,我们希望能够在从登录屏幕导航到注册或密码恢复屏幕时预先填充电子邮件字段(或反之亦然),这样,如果用户碰巧填写了错误的页面,则无需重新输入他们的电子邮件(或者,如果找不到用户名,你可能希望将用户转到注册表单)。
我们立即看到,将此组件中的 username
和 password
字段共享到另一个组件很困难。这是因为胖组件封装了其状态,根据定义,这使得从外部访问此状态变得困难。
将此组件重构并将状态代码从组件中拉出并放入应用程序的数据层更有意义。这可以像创建一个新模块一样简单:
// models/Auth.js
// 推荐
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;
然后,我们可以清理组件:
// views/Login.js
// 推荐
var Auth = require('../models/Auth');
var Login = {
view: function () {
return m('.login', [
m('input[type=text]', {
oninput: function (e) {
Auth.setUsername(e.target.value);
},
value: Auth.username,
}),
m('input[type=password]', {
oninput: function (e) {
Auth.setPassword(e.target.value);
},
value: Auth.password,
}),
m(
'button',
{
disabled: !Auth.canSubmit(),
onclick: Auth.login,
},
'Login'
),
]);
},
};
这样,Auth
模块现在是与身份验证相关的状态的真实来源,并且 Register
组件可以轻松访问此数据,甚至可以在需要时重用 canSubmit
之类的方法。此外,如果需要验证代码(例如,对于电子邮件字段),你只需要修改 setEmail
,并且该更改将为修改电子邮件字段的任何组件执行电子邮件验证。
作为奖励,请注意,我们不再需要使用 .bind
来保持对组件事件处理程序的状态的引用。
不要将 vnode.attrs
本身转发到其他 vnode
有时,你可能希望保持接口的灵活性并简化实现,方法是将属性转发到特定的子组件或元素,在本例中为 Bootstrap 的模态框。可能很想这样转发 vnode 的属性:
// 避免
var Modal = {
// ...
view: function (vnode) {
return m('.modal[tabindex=-1][role=dialog]', vnode.attrs, [
// 在此处转发 `vnode.attrs` ^
// ...
]);
},
};
如果你像上面这样做,则在使用它时可能会遇到问题:
var MyModal = {
view: function () {
return m(
Modal,
{
// 这会切换两次,因此不会显示
onupdate: function (vnode) {
if (toggle) $(vnode.dom).modal('toggle');
},
},
[
// ...
]
);
},
};
相反,你应该将_单个_属性转发到 vnode 中:
// 推荐
var Modal = {
// ...
view: function (vnode) {
return m('.modal[tabindex=-1][role=dialog]', vnode.attrs.attrs, [
// 在此处转发 `attrs:` ^
// ...
]);
},
};
// 示例
var MyModal = {
view: function () {
return m(Modal, {
attrs: {
// 这会切换一次
onupdate: function (vnode) {
if (toggle) $(vnode.dom).modal('toggle');
},
},
// ...
});
},
};
不要操作 children
如果组件对其应用属性或子组件的方式有特定的看法,则应切换为使用自定义属性。
通常,需要定义多组子组件,例如,如果组件具有可配置的标题和正文。
避免为此目的解构 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
:
// 推荐
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'),
});
静态定义组件,动态调用它们
避免在视图中创建组件定义
如果在 view
方法中创建组件(直接内联或通过调用执行此操作的函数),则每次重绘都会有该组件的不同克隆。在比较组件 vnode 时,如果新 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];
},
});
在上面的示例中,单击计数器组件按钮将增加其状态计数,但不会触发其视图,因为表示该组件的 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)];
},
});