创建基于浏览器的前端 UI
这是一篇协同翻译的文章,你可以点击『我来翻译』按钮来参与翻译。
Create a Browser-Based Frontend UI
The last piece of our application is the UI, which will be based on the [seed
framework](seed-rs.org/). We'll run this in the browser, by
cross-compiling our Rust code to WebAssembly (wasm). Please note that if you
just want to play with seed, you should check out the [quickstart
repo](github.com/David-OConnor/seed-quic...) that's linked from
their documentation. We're going to set things up from scratch below so that
you can get a feel for how everything is put together.
Cargo Workspace
This build works by generating a library. Cargo only allows one library per
crate. We already have a library. That seems like a problem right?
No problem -- cargo supports
where we can build multiple crates. We will build our backend (db + rest) into
one library crate and our frontend into a separate crate. Any shared structs
that we define will be in the root crate.
First, create a new crate as a subdirectory under our existing project
directory:
$ cargo new --lib frontend
Then we need to move our existing code into a new crate:
$ cargo new --lib backend
$ mv src/lib.rs src/db src/bin/ backend/src/
And fix up crate references in backend/src/bin/backend.rs and
backend/src/bin/todo.rs:
--- backend/src/bin/backend.rs
+++ backend/src/bin/backend.rs
@@ -9,8 +9,8 @@ extern crate serde;
use rocket_contrib::json::Json;
-use mytodo::db::{query_task, establish_connection};
-use mytodo::db::models::Task;
+use backend::db::{query_task, establish_connection};
+use backend::db::models::Task;
#[derive(Serialize)]
struct JsonApiResponse {
--- backend/src/bin/todo.rs
+++ backend/src/bin/todo.rs
@@ -1,5 +1,5 @@
use std::env;
-use mytodo::db::{create_task, query_task, establish_connection};
+use backend::db::{create_task, query_task, establish_connection};
fn help() {
println!("subcommands:");
We can build the backend and frontend by adding them as workspace members. We
will modify the Cargo.toml to look like this:
{{#include ../ch8-mytodo/Cargo.toml}}
Notice that we've dropped the dependencies -- there is no longer any need for
them in our root crate, but we need to add them to our backend/Cargo.toml:
{{#include ../ch8-mytodo/backend/Cargo.toml}}
Now you can do cargo build --all
to build both workspaces, or specify just
one with the -p
flag. For example, cargo build -p frontend
to build the
frontend workspace -- although it's empty at the moment.
Install wasm toolchain
I mentioned above that we're going to be cross-compiling our code to wasm32. In
order to do that we need to install the toolchain:
$ rustup target add wasm32-unknown-unknown
We also need to set up our crate to build wasm32 and add mytodo, seed,
wasm-bindgen, and web-sys as dependencies. Modify frontend/Cargo.toml:
{{#include ../ch8-mytodo/frontend/Cargo.toml}}
We need to install wasm-pack
, which requires some host-system support
packages in order for the installation to work:
$ sudo apt install libssl-dev pkg-config
$ cargo install wasm-pack
We can build the wasm package by doing:
$ cd frontend
$ wasm-pack build --target web --out-name package --dev
This will leave output in frontend/dev/. Having this extra command to run is
kind of tedious, especially with the two workspaces. Let's automate that out of
our way.
cargo make
cargo make
is a tool we can use to automate our build tasks. If you've ever
written a Makefile you have an idea of what cargo make can do -- but the modern
version adds about 100x more verbosity. On the bright side, cargo make's syntax
is much easier to fathom.
To install it, run cargo install cargo-make
.
To configure it, create a new file Makefile.toml in the root of our project
directory:
{{#include ../ch8-mytodo/Makefile.toml}}
This file just defines one task: The default
task is what gets run when you
just say cargo make
. (You can optionally specify a task name to run like
cargo make build
.) We've added clear = true
to this task because the tool
has a builtin default task that runs a bunch of other tasks -- these are
convenient, we don't want to get distracted by right now. The default
task
depends on the build
task.
The build
task is a built-in task that runs cargo build --all-features
,
which is perfect for what we need so we don't need to override it.
cargo make
knows about workspaces, and will run each task in each workspace.
But so far all we've got is what we had before -- we don't have it running
wasm-pack
yet. That's where the env
variable that is set at the top of the
file comes in. It means that cargo make
will look in workspace directories for
Makefile.toml files, and any tasks in those files will override the tasks in
the workspace-level Makefile.toml.
So let's override default
in frontend/Makefile.toml to do what we need:
{{#include ../ch8-mytodo/frontend/Makefile.toml}}
Here the default
task depends on create_wasm
which runs wasm-pack
as
mentioned above.
With all that in place, now just running cargo make
in the root will give us:
* backend library and binaries under target/debug
* browser-loadable web assembly package in frontend/pkg/package_bg.wasm
With all that build infrastructure out of the way, we can move on to coding the
UI.
Behind the Scenes
The way our frontend app is going to work:
* we write some Rust
* wasm-pack generates some files
* the .wasm file is a WebAssembly binary
* the .js file is a JavaScript loader that will pull in the wasm, and it acts
as the gatekeeper between JavaScript and Rust
* package.json has some metadata in case we want to integrate with npm and
friends
* we write an html stub file, that loads the .js, which loads the .wasm
* our app attaches itself to a DOM element in the html file
* the browser shows our app's UI elements
* our users rejoice
Create a Stub App
Create frontend/index.html:
{{#include ../ch8-mytodo/frontend/index.html}}
As you can see from the comment, this is based on a wasm-bindgen
example that
doesn't use a bundler (like webpack). This is for simplicity in this example --
in a larger app we may want other web assets that we want to pack together with
our application.
All our html needs to do is provide a div with an id of app
and the script
snippet that loads the package. Then the loader will take over and inject our
elements into the DOM.
Then we just need to add some code in frontend/src/lib.rs:
{{#include ../ch8-mytodo/frontend/src/lib.rs}}
Let's walk through this starting from the bottom. Everything kicks off with our
render
function because we added the start
attribute to the
#[wasm_bindgen]
macro. This sets things up so that our function is called as
soon as the module is loaded.
This function creates a seed app, passing in our init, update, and view
functions, and then launches the app.
Our init function gets called first, and is responsible for potentially doing
anything with an url path that the app was started from (we don't handle that
here -- we won't handle any routing at all in this guide). It then needs to
create and return a Model that will store state for the app. Our app just has
two silly states so that we can see how basic event handling works.
Moving up a block, our view function takes the model and returns a DOM node.
Here we're simply matching on coming or going and setting an appropriate
greeting in our <h1>
. Seed provides macros for all valid HTML5 tags, and as
you can see in the xample it also has macros for things like class and style.
Also you can see here how we've attached a simple event handler: whenever a
click occurs on our h1 (which is the entire size of the viewport thanks to the
styling) it will send a click message to our update function.
In our update function, we simply dispatch on the message type (there's only one
for this tiny example) and then toggle the model's direction. Our view will get
called again and the DOM will be re-rendered (we could call orders.skip()
to
prevent this), and we will see the greeting toggle.
And now that we gone over the basics we can move on to fetching and displaying
some tasks, so that how we can be more productive!
Fetch Tasks from Backend
Seed provides some useful tools for fetching data, so the first thing we need
to do is import those from the seed namespace in frontend/src/lib.rs:
{{#include ../ch8a-mytodo/frontend/src/lib.rs:use_seed}}
Then, since the first thing we want to do is load the tasks from the backend,
we'll change our init
function (and add a new function):
{{#include ../ch8a-mytodo/frontend/src/lib.rs:init}}
Let's talk a little bit about what's going on here, because it's not
necessarily obvious at first glance. In our original init
we just ignored the
Orders object. Now we're going to use it. Orders provides a mechanism for us to
be able to add messages or futures to a queue. We can send multiple messages or
futures and they will be performed in the order that we call the functions,
with futures being scheduled after the model update.
Since we want to fetch our tasks, we create a future using the Requests struct,
which is seed's wrapper around the [Fetch
API](developer.mozilla.org/en-US/docs/W...).
We create a new request for a hard-coded (gasp!) url, and then call its
fetch_json_data
method which returns a Future. This future will create the
Msg we provided, which will then get pumped into our update
function when
the request completes (or fails).
If we try compiling now, we get several errors. First, we haven't imported
Future
. Second, we forgot to define Msg::FetchedTasks
. The first one is
simplest so let's tackle that. First add a dependency on the futures crate to
frontend/Cargo.toml:
{{#include ../ch8a-mytodo/frontend/Cargo.toml:futures}}
and then add a use
in frontend/src/lib.rs:
{{#include ../ch8a-mytodo/frontend/src/lib.rs:future}}
To fix the second error we have to dive into what fetch_json_data
is really
doing with the Msg that we give it. What we're really providing ([as shown in
the api
docs](docs.rs/seed/0.4.0/seed/fetch/stru...))
is a FnOnce
that takes a ResponseDataResult<T>
where the latter is really a
type alias for a Result<T, FailReason<T>>
. Sheesh, that's a mouthful. But
really all we need to provide is an enum member that takes a
ResponseDataResult<T>
where T is a serde Deserialize. (I think that's
simpler. A little bit. How about an example?)
Back near the top of lib.rs, let's remove Msg::Click because we don't need it
any more, and add FetchedTasks:
{{#include ../ch8a-mytodo/frontend/src/lib.rs:msg}}
If we try to build now... oh no, we made it worse. Several of the errors are
about the newly-missing Click. As an exercise: go through and get rid of those
errors -- modify every place there's a reference to Click. It's easy. I'll be
here when you're done.
Hint: completely remove the simple_ev call in view, and the entire match arm in
update.
Ok now when we build we're down to just two compile errors. Both of these
are missing structs that we defined earlier in the backend crate. It seems
like the right thing to do would be to add a dependency on the backend crate
(you can try it and see what happens).
However, that is not the right thing. It is in fact the wrong thing. The
biggest and most immediately obvious reason is that the backend pulls in
dependencies that won't even build for wasm. The second is really kind of the
same reason: we don't want to be forced to build those extra dependencies, and
while certain techniques can be used to keep dependencies that we're not
actually using out of our final package, we don't want to risk bloating our
package with a bunch of unneeded stuff.
A better solution is to simply move the structs up into the root of our project.
We need serde, so add it to Cargo.toml:
{{#include ../ch8a-mytodo/Cargo.toml:serde}}
Create a new file src/lib.rs:
{{#include ../ch8a-mytodo/src/lib.rs}}
This is pretty straightforward: we just define the two structs we need, deriving
from serde so that we have bidirectional serialization, and from Clone and Debug
so we can have that easily on our Msg type.
Then we can modify our backend REST API to use the new structs. The backend
needs to add the root crate as a dependency in backend/Cargo.toml:
{{#include ../ch8a-mytodo/backend/Cargo.toml:mytodo}}
And in backend.rs we need to remove our existing definition of JsonApiResponse,
remove the use of backend::db::Task, and add a use from mytodo:
{{#include ../ch8a-mytodo/backend/src/bin/backend.rs:use}}
With this change, the backend almost builds. Unfortunately, almost doesn't
count with compilers. This error is interesting:
error[E0308]: mismatched types
--> backend/src/bin/backend.rs:21:28
|
21 | response.data.push(task);
| ^^^^ expected struct `mytodo::Task`, found struct `backend::db::models::Task`
|
= note: expected type `mytodo::Task`
found type `backend::db::models::Task`
Our loop is trying to push a database-task, but our response object wants an
api-task. Obviously we should get rid of the db::models::Task struct and just
have it use the mytodo::Task struct instead, right? The way it is now is
repetitive, and that violates DRY, and we want to stay DRY!
Well, let's think about how the different pieces are potentially going to
change. We might enhance our application in many different ways. Some of those
ways might change our database schema -- which will require changes to
db::models structs. We would like to avoid being forced to change our REST API
models every time our database changes.
Right now it seems like it's repetitive, but that's only because our task model
is ultra-simple. If our app grows new features it's very likely we will need two
different models, so we will keep them separate. And since they're separate, we
need to manually convert from db_task into api_task in our loop over the query:
{{#include ../ch8a-mytodo/backend/src/bin/backend.rs:for}}
All right! Now our backend builds cleanly, and there's only one more (easy)
error to fix up in the frontend!
error[E0004]: non-exhaustive patterns: pattern `FetchedTasks` of type `Msg` is not handled
--> frontend/src/lib.rs:25:11
|
20 | / enum Msg {
21 | | FetchedTasks(fetch::ResponseDataResult<JsonApiResponse>),
| | ------------ variant not covered
22 | | }
| |_- `Msg` defined here
...
25 | match msg {
| ^^^
|
= help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
We just need to handle Msg::FetchedTasks in update:
{{#include ../ch8a-mytodo/frontend/src/lib.rs:update}}
Here we pattern-match on the Ok or Err of the Result, logging the latter to the
console. (In production we'd probably want to notify the user or retry the
operation.) If we get an Ok then the fetch has succeeded and we should update the
model so we can render the tasks in the DOM. But we're stubbing it now so we can
finally have a successful build after that long refactoring.
A Side Note On Refactoring and Testing
In retrospect, we could have made that refactoring in smaller, safer chunks if
we had known in advance how many things we were going to have to change. A
better approach would have been to move the structs up to the root first, then
fix up the backend, add the fetched message, and finally remove the click
message. Each of those steps could have been built and tested separately. But
since we're stumbling through the changes a little bit we let the compiler guide
us through the refactorings to a large extent.
Also, refactorings like this get scary as the app gets bigger. It's still small
enough that we can easily test the backend, the frontend, and the end-to-end by
hand. If we are serious about this at all, we'd definitely want some unit and
integration tests wrapped around our app. I've left tests out of the scope of
this book for the sake of brevity, clarity, and forward momentum, but they're a
critical piece of any development effort, and they make up a big part of an
upcoming book ("Engineering Rust Web Applications") that will be the big brother
this little guide always wanted.
Displaying the Tasks
We have some tasks we want to display. Our displaying machinery lives in the
view function. We need a way to get the tasks from our update function (where we
get the fetch result) to the view function (where we make the nodes). The one
thing these have in common is our Model. Let's remove the now-useless Direction
struct and replace it with a Vec<Task>
. We have to touch a few different
places, so here's the whole frontend/src/lib.rs:
{{#include ../ch8b-mytodo/frontend/src/lib.rs}}
Notice that we've deleted the Direction struct and replaced it's presence in
Model with a vector of tasks.
In update we set the model to contain the vec from the result.
In view, the h1 now just contains a heading "Tasks" and we've set up a ul
underneath it. At the top of the function we're mapping over the tasks in the
model to create some li elements that we can hang off the ul.
Everything builds cleanly! Let's test it. In one window, start the backend:
$ cargo run -p backend --bin backend
In another window, serve the frontend using a convenient Rust crate that
simply serves the current directory from a small web server:
$ cd frontend
$ cargo install microserver
$ microserver
Browse to localhost:9090/ and... and... nothing!? So disappointing.
We can see in the window that's running our backend that a GET request came in.
So we know something is happening.
Let's open developer tools (in Chrome, Ctrl+Shift+I) and look first at the
console (whole lot of nothing) and then at the network tab to see what it's
doing with the json request to our backend. Hmm, the tasks request is showing as
red, that can't be good.
Ahh, we forgot about CORS ([Cross-Origin Resource
Sharing](developer.mozilla.org/en-US/docs/W...)). Since our
REST API is being served from port 8000 and our main page is being served from
9090, they're two separate origins, and we have to make sure our backend is
returning the proper CORS headers.
This is a bad news / good news thing. Great news, really. The bad news is that
we have a little more work to do. The great news is that it's really simple to
get it working for our small example.
Adding CORS Support in the Backend
Diving right in, the support we need for CORS is in the rocket_cors crate, so
change backend/Cargo.toml:
{{#include ../ch8b-mytodo/backend/Cargo.toml:cors}}
And at the top of backend.rs we need to use rocket_cors:
{{#include ../ch8b-mytodo/backend/src/bin/backend.rs:use_cors}}
Then add the CORS options to main:
{{#include ../ch8b-mytodo/backend/src/bin/backend.rs:main}}
It's worth noting that we could be more restrictive with our options here -- for
our purposes today we just want to open it up, but for a public-facing
application we would want to carefully examine the options in the
rocket_cors docs.
Now we can try serving the backend and frontend in separate windows again, and
refreshing our browser.
Victory! You should now see the two tasks we defined earlier. If you open yet
another window, run:
$ cargo run -p backend --bin todo new celebrate
and then refresh the browser, you will see the new task.
Frontend Wrap-Up
We've built a functional web application, from the bottom up, almost entirely in
Rust!
But ... there are also an awful lot of things that have been left out of this
guide:
* The frontend doesn't do any kind of user input. None. Nada.
* (I am going to fix this in an update. Stay tuned.)
* The frontend doesn't do any kind of routing -- no browsing to multiple pages
within the app, no pagination, etc.
* There are ZERO tests. I feel kind of dirty.
* Also, ZERO attempts at error handling. Any component will panic if anything
goes the least bit wrong.
* I only scratched the surface of cargo make
.
* There's nary a mention of continuous integration.
* Nothing about app deployment, upgrades, troubleshooting/debugging, or
maintenance.
* The data model in the database and REST API is trivial; there are interesting
ways that this could be made more instructive.
* It's out of compliance with the JSON API spec.
* No users or authentication, or security of any sort.
* No web-side external plugins (e.g. npm packages).
* Nothing about interfacing directly with JavaScript.
But ... my intent for the scope of this guide was to show how to put together an
all-Rust stack for getting an application skeleton built from end-to-end. In a
short guide. With the exception of user input in the web ui, I think I've done
that.
Those missing pieces are important! I have a rough outline for a full-length
follow-up to this book ("Engineering Rust Web Applications") that will cover
those topics and more, with a more rigorous approach, but similar style. It
won't be based around a todo app -- it's tentatively a library management
system.
I'd love to hear your feedback and/or corrections. Please email info at
erwabook.com or open an issue at
To get updates on this book, email-only draft chapters of "Engineering Rust Web
Applications", or other Rust articles that land on this site, subscribe:
<link href="//cdn-images.mailchimp.com/embedcode/classic-10_7.css" rel="stylesheet" type="text/css">
<style type="text/css">
mc_embed_signup{background:#fff; clear:left; font:14px Helvetica,Arial,sans-serif; }
/* Add your own Mailchimp form style overrides in your site stylesheet or in this style block.
We recommend moving this block and the preceding CSS link to the HEAD of your HTML file. */
<div id="mc_embed_signup">
<form action="erwabook.us3.list-manage.com/subsc..." method="post" id="mc-embedded-subscribe-form" name="mc-embedded-subscribe-form" class="validate" target="_blank" novalidate>
<div id="mc_embed_signup_scroll">
Subscribe
<div class="indicates-required"><span class="asterisk">* indicates required
<div class="mc-field-group">
<label for="mce-EMAIL">Email Address <span class="asterisk">*
<input type="email" value="" name="EMAIL" class="required email" id="mce-EMAIL">
<div class="mc-field-group">
<label for="mce-FNAME">First Name
<input type="text" value="" name="FNAME" class="" id="mce-FNAME">
<div class="mc-field-group input-group">
Email Format
- html
- text
<div id="mce-responses" class="clear">
<div class="response" id="mce-error-response" style="display:none">
<div class="response" id="mce-success-response" style="display:none">
<div style="position: absolute; left: -5000px;" aria-hidden="true"><input type="text" name="b_3a582ffabb1caad1f309a702d_4aa892e8ce" tabindex="-1" value="">
<div class="clear"><input type="submit" value="Subscribe" name="subscribe" id="mc-embedded-subscribe" class="button">
<script type='text/javascript' src='//s3.amazonaws.com/downloads.mailchimp.com/js/mc-validate.js'><script type='text/javascript'>(function($) {window.fnames = new Array(); window.ftypes = new Array();fnames[0]='EMAIL';ftypes[0]='email';fnames[1]='FNAME';ftypes[1]='text';fnames[2]='LNAME';ftypes[2]='text';fnames[3]='ADDRESS';ftypes[3]='address';fnames[4]='PHONE';ftypes[4]='phone';fnames[5]='BIRTHDAY';ftypes[5]='birthday';}(jQuery));var $mcj = jQuery.noConflict(true);
Full-Stack Exercise
With all of that out of the way, let's try one last exercise. This is going to
be a feature that slices all the way up the stack: due dates.
There are two ways to do this.
The Right Way™ -- store a proper date type in the database, carry it up through
the models as a date type, and convert it to a string at the last minute before
presenting it to the user. (Obviously this is the only way to do it for a real
app.) Give yourself 3 stars if you tackle it this way.
The Easy Way™ -- if you're just interested in seeing all the places you have to
touch to make a feature like this work, just store it as a string in the db, and
bring it up through the stack as a string that you can show directly to the
user. As a bonus, if you implement this method, you can have a task like
"exercise more" or "start a budget" with a due date of "tomorrow" and then you
never have to follow through! (If you do this and try to use it for real, you're
going to end up filled with self loathing. "Fix the ** app" will be one of your
tasks, with a due date of "yesterday".)
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
推荐文章: