Mithril 简单应用教程

简单应用教程

初始化应用

让我们来开发一个简单的应用,通过这个应用可以学习到开发单页面应用所需的主要知识。

首先在项目文件夹下创建应用的入口文件 index.html

<!doctype html>
<html>
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>My Application</title>
    </head>
    <body>
        <script src="bin/app.js"></script>
    </body>
</html>

为了便于维护代码,我们需要把代码分成多个模块,并最终把这些模块合并成一个包 bin/app.js

我们使用 NPM 包管理器来管理打包工具。请按照安装页面的说明使用 npm 进行安装。安装完成后就可以准备创建引用了。

创建模型

我们首先创建一个模型文件 src/models/User.js,并添加了一个 list 方法用于保存用户对象:

var User = {
    list: []
}

module.exports = User

在这个应用中我们需要从服务器加载数据。为了与服务器通信,我们需要使用 Mithril 的 XHR 工具:m.request。首先,在模块中引入 Mithril:

var m = require("mithril")

var User = {
    list: []
}

module.exports = User

接下来,创建一个用于触发 XHR 请求的函数。我们把它命名为 loadList

var m = require("mithril")

var User = {
    list: [],
    loadList: function() {
        // TODO: make XHR call
    }
}

module.exports = User

然后,我们用 m.request 来发送 XHR 请求,并用接口的响应来填充数据。

var m = require("mithril")

var User = {
    list: [],
    loadList: function() {
        return m.request({
            method: "GET",
            url: "http://rem-rest-api.herokuapp.com/api/users",
            withCredentials: true,
        })
        .then(function(result) {
            User.list = result.data
        })
    },
}

module.exports = User

m.request 返回一个 Promise。默认情况下,Mithril 会把 HTTP 请求的响应数据当成 JSON 格式,并自动解析为 JavaScript 对象或数组。.then 回调会在 XHR 请求完成后运行。

我们在 loadList 中使用了 return 语句。这在使用 Promise 时是一个很好的做法,它允许我们注册更多的回调,以便在 XHR 请求完成后运行。

这个简单的模型暴露了两个方法:User.list(一个保存用户对象的数组),User.loadList(一个把服务器返回的数据填充到 User.list 的方法)。

创建视图

现在,我们创建一个视图文件 src/views/UserList.js,用于显示来自 User 模型的数据。

首先,引入 Mithril 和 User 模型,因为在视图中会同时用到这两个模块:

var m = require("mithril")
var User = require("../models/User")

然后,创建一个 Mithril 组件。组件只是一个包含 view 方法的对象:

var m = require("mithril")
var User = require("../models/User")

module.exports = {
    view: function() {
        // TODO add code here
    }
}

接着我们用 Mithril hyperscript 来创建一个列表。Hyperscript 是编写视图最常用方式,当然你也可以用 JSX 来创建视图。

var m = require("mithril")
var User = require("../models/User")

module.exports = {
    view: function() {
        return m(".user-list")
    }
}

.user-list 是一个 CSS 选择器。当没有指定标签时,默认使用 div,所以这个视图和 <div class="user-list"></div> 等效。

现在,我们从之前创建的 User 模型中引用用户列表,以循环数据:

var m = require("mithril")
var User = require("../models/User")

module.exports = {
    view: function() {
        return m(".user-list", User.list.map(function(user) {
            return m(".user-list-item", user.firstName + " " + user.lastName)
        }))
    }
}

因为 User.list 是一个 JavaScript 数组,而 hyperscript 视图是 JavaScript 代码,所以我们可以用 .map 方法来循环这个数组。这创建了一个由 div 组成的 vnode 数组,每一个都包含一个用户的名称。

问题是,我们从来没有调用过 User.list 方法,因此 User.list 仍是一个空数组,且此视图也将显示空白页。我们希望在渲染这个组件时,能自动调用 User.list,我们可以使用组件的生命周期方法来实现:

var m = require("mithril")
var User = require("../models/User")

module.exports = {
    oninit: User.loadList,
    view: function() {
        return m(".user-list", User.list.map(function(user) {
            return m(".user-list-item", user.firstName + " " + user.lastName)
        }))
    }
}

我们向组件添加了一个 oninit 方法,该方法引用了 User.loadList。这意味着在组件初始化时,会自动调用 User.list 方法。

注意:这里我们用的不是 oninit: User.loadList()(末尾带括号)。区别是 oninit: User.loadList() 会立即调用,即使组件未被渲染;且只会调用一次,即使重新创建组件,也不会被再次调用。而 oninit: User.loadList 只有在渲染组件时才会被调用。

渲染视图

我们创建一个入口文件 src/index.js,在该文件中来渲染视图:

var m = require("mithril")

var UserList = require("./views/UserList")

m.mount(document.body, UserList)

调用 m.mount 把指定的组件(UserList)渲染到 DOM 元素(document.body)中,并移除先前存在的任何 DOM。现在在浏览器中打开这个 HTML 文件,会显示人名列表。

添加样式

现在列表看起来很简陋,因为我们还没有添加任何样式。

我们创建一个 styles.css 文件,并在 index.html 文件中引入它:

<!doctype html>
<html>
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>My Application</title>
        <link href="styles.css" rel="stylesheet" />
    </head>
    <body>
        <script src="bin/app.js"></script>
    </body>
</html>

现在在 styles.css 文件中来为 UserList 组件编写样式:

.user-list {list-style:none;margin:0 0 10px;padding:0;}
.user-list-item {background:#fafafa;border:1px solid #ddd;color:#333;display:block;margin:0 0 1px;padding:8px 15px;text-decoration:none;}
.user-list-item:hover {text-decoration:underline;}

现在刷新浏览器就能看到带样式的列表了。

添加路由

路由提供了页面切换功能。我们通过 m.route 来添加路由:

var m = require("mithril")

var UserList = require("./views/UserList")

m.route(document.body, "/list", {
    "/list": UserList
})

m.route 的第一个参数指定了组件会被渲染到 document.body 元素中。第二个参数是默认路由,当访问的 URL 对应的路由不存在时,则会重定向到该路由。第三个参数是路由和对应的组件的映射,定义了每个路由会解析哪个组件。

现在刷新浏览器,URL 后面会被追加 #!/list,该路由对应的是 UserList 组件,所以页面上会看到人名列表。

字符串 #! 称为 hashbang,它常用于实现客户端路由,可以通过 m.route.prefix 来配置该字符串。因为有些配置需要配合服务器端进行更改,所以本教程中继续使用 #! 作为 hashbang。

编辑用户

我们为应用添加一个编辑用户功能。首先创建一个视图文件 src/views/UserForm.js

/module.exports = {
    view: function() {
        // TODO implement view
    }
}

然后在 src/index.js 文件中引入该模块:

var m = require("mithril")

var UserList = require("./views/UserList")
var UserForm = require("./views/UserForm")

m.route(document.body, "/list", {
    "/list": UserList
})

最后,创建一个路由来访问该模块:

var m = require("mithril")

var UserList = require("./views/UserList")
var UserForm = require("./views/UserForm")

m.route(document.body, "/list", {
    "/list": UserList,
    "/edit/:id": UserForm,
})

注意,新路由中有一个 :id,这是一个路由参数,在后面会用到。

我们来实现 UserForm 组件:

var m = require("mithril")

module.exports = {
    view: function() {
        return m("form", [
            m("label.label", "First name"),
            m("input.input[type=text][placeholder=First name]"),
            m("label.label", "Last name"),
            m("input.input[placeholder=Last name]"),
            m("button.button[type=submit]", "Save"),
        ])
    }
}

并在 styles.css 中添加一些样式:

body,.input,.button {font:normal 16px Verdana;margin:0;}

.user-list {list-style:none;margin:0 0 10px;padding:0;}
.user-list-item {background:#fafafa;border:1px solid #ddd;color:#333;display:block;margin:0 0 1px;padding:8px 15px;text-decoration:none;}
.user-list-item:hover {text-decoration:underline;}

.label {display:block;margin:0 0 5px;}
.input {border:1px solid #ddd;border-radius:3px;box-sizing:border-box;display:block;margin:0 0 10px;padding:10px 15px;width:100%;}
.button {background:#eee;border:1px solid #ddd;border-radius:3px;color:#333;display:inline-block;margin:0 0 10px;padding:10px 15px;text-decoration:none;}
.button:hover {background:#e8e8e8;}

现在组件还不会响应用户事件。我们需要在 User 模型中添加一些代码。这是 User 模型之前的代码:

var m = require("mithril")

var User = {
    list: [],
    loadList: function() {
        return m.request({
            method: "GET",
            url: "http://rem-rest-api.herokuapp.com/api/users",
            withCredentials: true,
        })
        .then(function(result) {
            User.list = result.data
        })
    },
}

module.exports = User

我们来添加一些代码,使我们可以加载单个用户:

var m = require("mithril")

var User = {
    list: [],
    loadList: function() {
        return m.request({
            method: "GET",
            url: "http://rem-rest-api.herokuapp.com/api/users",
            withCredentials: true,
        })
        .then(function(result) {
            User.list = result.data
        })
    },

    current: {},
    load: function(id) {
        return m.request({
            method: "GET",
            url: "http://rem-rest-api.herokuapp.com/api/users/:id",
            data: {id: id},
            withCredentials: true,
        })
        .then(function(result) {
            User.current = result
        })
    }
}

module.exports = User

注意,我们添加了一个 User.current 属性,和一个 User.load(id) 方法,该方法会把当前用户的信息填充到 User.current 属性中。现在我们可以用这个新的方法来填充 UserForm 视图:

var m = require("mithril")
var User = require("../models/User")

module.exports = {
    oninit: function(vnode) {User.load(vnode.attrs.id)},
    view: function() {
        return m("form", [
            m("label.label", "First name"),
            m("input.input[type=text][placeholder=First name]", {value: User.current.firstName}),
            m("label.label", "Last name"),
            m("input.input[placeholder=Last name]", {value: User.current.lastName}),
            m("button.button[type=submit]", "Save"),
        ])
    }
}

UserList 组件类似,我们在 oninit 方法中调用 User.load()。还记得在前面的 "/edit/:id": UserForm 路由中有一个路由参数 :id 吗?该路由参数会成为 UserForm 组件的 vnode 的属性,所以路由 /edit/1 会使 vnode.attrs.id 的值为 1

现在我们来修改 UserList 视图,使它可以链接到 UserForm 视图:

var m = require("mithril")
var User = require("../models/User")

module.exports = {
    oninit: User.loadList,
    view: function() {
        return m(".user-list", User.list.map(function(user) {
            return m("a.user-list-item", {href: "/edit/" + user.id, oncreate: m.route.link}, user.firstName + " " + user.lastName)
        }))
    }
}

我们把 .user-list-item 改成了 a.user-list-item,添加了 href 指向目标路由。我们还添加了 oncreate: m.route.link,这会使该链接变成一个路由链接,当点击链接时,会改变 URL 中 #! 后面的部分,但不会导致整个页面刷新。

现在你刷新该页面,就能看到人名列表,点击某一个人名,就能进入表单。你可以通过浏览器的返回按钮回到人名列表。

保存用户

现在你点击“保存”按钮,还无法保存表单。我们继续来让这个表单可以工作:

var m = require("mithril")
var User = require("../models/User")

module.exports = {
    oninit: function(vnode) {User.load(vnode.attrs.id)},
    view: function() {
        return m("form", [
            m("label.label", "First name"),
            m("input.input[type=text][placeholder=First name]", {
                oninput: m.withAttr("value", function(value) {User.current.firstName = value}),
                value: User.current.firstName
            }),
            m("label.label", "Last name"),
            m("input.input[placeholder=Last name]", {
                oninput: m.withAttr("value", function(value) {User.current.lastName = value}),
                value: User.current.lastName
            }),
            m("button.button[type=submit]", {onclick: User.save}, "Save"),
        ])
    }
}

我们在输入框中添加了事件 oninput,该事件会把用户的输入实时更新到 User.current.firstNameUser.current.lastName 中。

此外,我们声明了在按下“保存”按钮时,调用 User.save 方法,下面我们来实现这个方法:

var m = require("mithril")

var User = {
    list: [],
    loadList: function() {
        return m.request({
            method: "GET",
            url: "http://rem-rest-api.herokuapp.com/api/users",
            withCredentials: true,
        })
        .then(function(result) {
            User.list = result.data
        })
    },

    current: {},
    load: function(id) {
        return m.request({
            method: "GET",
            url: "http://rem-rest-api.herokuapp.com/api/users/:id",
            data: {id: id},
            withCredentials: true,
        })
        .then(function(result) {
            User.current = result
        })
    },

    save: function() {
        return m.request({
            method: "PUT",
            url: "http://rem-rest-api.herokuapp.com/api/users/:id",
            data: User.current,
            withCredentials: true,
        })
    }
}

module.exports = User

在最下面的 save 方法中,我们把 User.current 中的数据传输到了服务器端。

现在试着编辑应用中的用户名。保存更改后,应该可以看到用户列表中的用户名也发生了更改。

组件布局

目前,我们还只能通过浏览器的后退按钮返回到用户列表。下面我们来为应用添加一个全局菜单。

首先创建一个文件 src/views/Layout.js

var m = require("mithril")

module.exports = {
    view: function(vnode) {
        return m("main.layout", [
            m("nav.menu", [
                m("a[href='/list']", {oncreate: m.route.link}, "Users")
            ]),
            m("section", vnode.children)
        ])
    }
}

这个组件非常简单,它包含了一个指向用户列表的链接,我们为这个链接加上了 m.route.link 使它称为一个路由链接。

这个组件还包含一个 <section> 元素,它的子元素是 vnode.childrenvnode 是 Layout 组件实例的引用。vnode.children 则表示 vnode 中的所有子元素。

我们来添加一些样式:

body,.input,.button {font:normal 16px Verdana;margin:0;}

.layout {margin:10px auto;max-width:1000px;}
.menu {margin:0 0 30px;}

.user-list {list-style:none;margin:0 0 10px;padding:0;}
.user-list-item {background:#fafafa;border:1px solid #ddd;color:#333;display:block;margin:0 0 1px;padding:8px 15px;text-decoration:none;}
.user-list-item:hover {text-decoration:underline;}

.label {display:block;margin:0 0 5px;}
.input {border:1px solid #ddd;border-radius:3px;box-sizing:border-box;display:block;margin:0 0 10px;padding:10px 15px;width:100%;}
.button {background:#eee;border:1px solid #ddd;border-radius:3px;color:#333;display:inline-block;margin:0 0 10px;padding:10px 15px;text-decoration:none;}
.button:hover {background:#e8e8e8;}

然后修改 src/index.js 文件,把布局添加路由当中:

var m = require("mithril")

var UserList = require("./views/UserList")
var UserForm = require("./views/UserForm")
var Layout = require("./views/Layout")

m.route(document.body, "/list", {
    "/list": {
        render: function() {
            return m(Layout, m(UserList))
        }
    },
    "/edit/:id": {
        render: function(vnode) {
            return m(Layout, m(UserForm, vnode.attrs))
        }
    },
})

我们把每一个组件都替换成了 RouteResolver(一个含 render 方法的对象)。render 方法的写法和普通组件的写法一样,嵌套调用 m()

值得注意的是,m() 函数的第一个参数用的是组件,而不是选择器。在 /list 路由中,我们用了 m(Layout, m(UserList))。这意味着用 Layout 组件作为根 vnode,UserList 则是它的唯一子元素。

/edit/:id 路由中,vnode 参数把路由参数传入到了 UserForm 组件中。如果 URL 是 /edit/1,那么 vnode.attrs 则是 {id: 1}m(UserForm, vnode.attrs)m(UserForm, {id: 1}) 是等效的。等效的 JSX 代码为 <UserForm id={vnode.attrs.id} />

现在刷新页面,你会看到在应用的每个页面上都有一个全局菜单。

结束

本教程到此结束。

在本教程中,我们创建了一个非常简单的应用,我们可以从服务器获取用户列表,编辑用户,并保存到服务器。你可以尝试自己来实现用户的创建和删除功能。

你可以在示例页面看到更多 Mithril 的代码示例。