让我们来开发一个简单的应用,通过这个应用可以学习到开发单页面应用所需的主要知识。
首先在项目文件夹下创建应用的入口文件 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.firstName
和 User.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.children
。vnode
是 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 的代码示例。