介绍
现代 JavaScript 库(如 React.js 和 Vue.js)的出现使前端 Web 开发变得更好。这些库附带的功能包括 SPA(单页应用程序),它是在网页中动态加载内容,而无需完全重新加载到浏览器。
大多数单页应用程序背后的概念是客户端呈现。在客户端渲染中,大部分内容使用 JavaScript 在浏览器中渲染;在页面加载时,在 JavaScript 完全下载并呈现网站的其余部分之前,内容最初不会加载。
客户端渲染是一个相对较新的概念,它的使用需要权衡。一个显着的负面影响是,由于在使用 JavaScript 更新页面之前不会完全呈现内容,因此网站的 SEO(搜索引擎优化)将受到影响,因为搜索引擎几乎没有任何数据可供抓取。
另一方面,服务器端呈现是在浏览器上呈现 HTML 页面的传统方式。在较旧的服务器端呈现应用程序中,Web 应用程序是使用服务器端语言(例如 PHP)构建的。当浏览器请求网页时,远程服务器会添加(动态)内容并提供填充的 HTML 页面。
正如客户端渲染的缺点一样,服务器端渲染会使浏览器过于频繁地发送服务器请求,并为类似的数据执行重复的整页重新加载。有一些 JavaScript 框架可以最初使用 SSR(服务器端渲染)解决方案加载网页,然后使用框架来处理进一步的动态路由并仅获取必要的数据。生成的应用程序称为通用应用程序。
总之,通用应用程序用于描述可以在客户端和服务器端执行的 JavaScript 代码。在本文中,我们将使用 Nuxt.js 构建一个通用食谱应用程序。
Nuxt.js 是用于开发通用 Vue.js 应用程序的更高级别的框架。它的创建受到 React 的Next.js 的启发,它有助于抽象设置服务器端呈现的 Vue.js 应用程序时出现的困难(服务器配置和客户端代码分发)。Nuxt.js 还附带有助于客户端和服务器端之间开发的功能,例如异步数据、中间件、布局等。
注意:我们可以将我们构建的应用程序称为服务端渲染(SSR),因为在我们创建单页应用程序时,Vue.js 默认已经实现了客户端渲染。该应用程序实际上是一个通用应用程序。
在本文中,我们将看到如何使用 Django 和 Nuxt.js 创建通用应用程序。Django 将处理后端操作并使用 DRF(Django Rest Framework)提供 API,而 Nuxt.js 将创建前端。
这是最终应用程序的演示:
我们看到最终的应用程序是一个执行 CRUD 操作的配方应用程序。
先决条件
要学习本教程,您需要在您的机器上安装以下软件:
- Node.js 安装在本地,您可以按照如何安装 Node.js 和创建本地开发环境来完成。
- 该项目需要在本地环境中安装 Python。
- 该项目将使用Pipenv。一个生产就绪的工具,旨在将所有打包世界中最好的东西带到 Python 世界。它将 Pipfile、pip 和virtualenv整合到一个命令中。
本教程假设读者具备以下条件:
- Django和Django REST Framework 的基本工作知识。
- Vue.js 的基本工作知识。
本教程已通过 Python v3.7.7、Django v3.0.7、Node v14.4.0、npm
v6.14.5 和nuxt
v2.13.0 验证。
第 1 步 – 设置后端
在本节中,我们将设置后端并创建启动和运行所需的所有目录,因此启动终端的新实例并通过运行以下命令创建项目目录:
- mkdir recipes_app
接下来,我们将导航到目录:
- cd recipes_app
现在,我们将使用 Pip 安装 Pipenv:
- pip install pipenv
并激活一个新的虚拟环境:
- pipenv shell
注意:如果您的计算机上已经安装了 Pipenv,您应该跳过第一个命令。
让我们使用 Pipenv 安装 Django 和其他依赖项:
- pipenv install django django-rest-framework django-cors-headers
注意:使用 Pipenv 激活新的虚拟环境后,终端中的每个命令行都会以当前工作目录的名称为前缀。在这种情况下,它是(recipes_app)
。
现在,我们将创建一个名为 的新 Django 项目api
:
- django-admin startproject api
导航到项目目录:
- cd api
创建一个名为 的 Django 应用程序core
:
- python manage.py startapp core
让我们将core
应用程序与rest_framework
和一起注册cors-headers
,以便 Django 项目识别它。打开api/settings.py
文件并相应地更新它:
# ...
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework', # add this
'corsheaders', # add this
'core' # add this
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # add this
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
# add this block below MIDDLEWARE
CORS_ORIGIN_WHITELIST = (
'http://localhost:3000',
)
# ...
# add the following just below STATIC_URL
MEDIA_URL = '/media/' # add this
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # add this
我们添加http://localhost:3000
到白名单是因为客户端应用程序将在该端口上提供服务,并且我们希望防止CORS(跨源资源共享)错误。我们还添加了MEDIA_URL
andMEDIA_ROOT
因为在应用程序中提供图像时我们将需要它们。
定义配方模型
让我们创建一个模型来定义 Recipe 项应如何存储在数据库中,打开core/models.py
文件并将其完全替换为以下代码段:
from django.db import models
# Create your models here.
class Recipe(models.Model):
DIFFICULTY_LEVELS = (
('Easy', 'Easy'),
('Medium', 'Medium'),
('Hard', 'Hard'),
)
name = models.CharField(max_length=120)
ingredients = models.CharField(max_length=400)
picture = models.FileField()
difficulty = models.CharField(choices=DIFFICULTY_LEVELS, max_length=10)
prep_time = models.PositiveIntegerField()
prep_guide = models.TextField()
def __str_(self):
return "Recipe for {}".format(self.name)
上面的代码片段描述了 Recipe 模型的六个属性:
name
ingredients
picture
difficulty
prep_time
prep_guide
为配方模型创建序列化程序
我们需要序列化器将模型实例转换为 JSON,以便前端可以处理接收到的数据。我们将创建一个core/serializers.py
文件并使用以下内容更新它:
from rest_framework import serializers
from .models import Recipe
class RecipeSerializer(serializers.ModelSerializer):
class Meta:
model = Recipe
fields = ("id", "name", "ingredients", "picture", "difficulty", "prep_time", "prep_guide")
在上面的代码片段中,我们指定了要使用的模型以及要转换为 JSON 的字段。
设置管理面板
Django 为我们提供了一个开箱即用的管理界面;该界面将使在我们刚刚创建的 Recipe 模型上测试 CRUD 操作变得容易,但首先,我们将进行一些配置。
打开core/admin.py
文件并将其完全替换为以下代码段:
from django.contrib import admin
from .models import Recipe # add this
# Register your models here.
admin.site.register(Recipe) # add this
创建视图
让我们RecipeViewSet
在core/views.py
文件中创建一个类,用下面的代码片段完全替换它:
from rest_framework import viewsets
from .serializers import RecipeSerializer
from .models import Recipe
class RecipeViewSet(viewsets.ModelViewSet):
serializer_class = RecipeSerializer
queryset = Recipe.objects.all()
该viewsets.ModelViewSet
提供的方法默认情况下处理CRUD操作。我们只需要指定序列化器类和queryset
.
设置 URL
转到api/urls.py
文件并用下面的代码完全替换它。此代码指定 API 的 URL 路径:
from django.contrib import admin
from django.urls import path, include # add this
from django.conf import settings # add this
from django.conf.urls.static import static # add this
urlpatterns = [
path('admin/', admin.site.urls),
path("api/", include('core.urls')) # add this
]
# add this
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
现在,urls.py
在core
目录中创建一个文件并粘贴以下代码段:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import RecipeViewSet
router = DefaultRouter()
router.register(r'recipes', RecipeViewSet)
urlpatterns = [
path("", include(router.urls))
]
在上面的代码中,router
该类生成以下 URL 模式:
/recipes/
– 可以在此路由上执行 CREATE 和 READ 操作。/recipes/{id}
– 可以在此路由上执行 READ、UPDATE 和 DELETE 操作。
运行迁移
因为我们最近创建了一个 Recipe 模型并定义了它的结构,所以我们需要制作一个迁移文件并将模型上的更改应用到数据库中,所以让我们运行以下命令:
- python manage.py makemigrations
- python manage.py migrate
现在,我们将创建一个超级用户帐户来访问管理界面:
- python manage.py createsuperuser
系统将提示您输入超级用户的用户名、电子邮件和密码。请务必输入您能记住的详细信息,因为您很快就会需要它们来登录管理仪表板。
这就是需要在后端完成的所有配置。我们现在可以测试我们创建的 API,所以让我们启动 Django 服务器:
- python manage.py runserver
服务器运行后,请转到localhost:8000/api/recipes/
以确保其正常工作:
我们可以使用界面创建一个新的 Recipe 项:
我们还可以使用id
主键对特定 Recipe 项执行 DELETE、PUT 和 PATCH 操作。为此,我们将访问具有此结构的地址/api/recipe/{id}
。让我们试试这个地址—— localhost:8000/api/recipes/1
:
这就是应用程序后端的全部内容,现在我们可以继续充实前端。
第 2 步 – 设置前端
在本教程的这一部分中,我们将构建应用程序的前端。我们想将前端代码的目录放在目录的根recipes_app
目录中。因此,api
在运行本节中的命令之前,导航出目录(或启动一个新的终端以与前一个终端一起运行)。
让我们创建一个使用此命令nuxt
调用的应用程序client
:
- npx create-nuxt-app client
注:前段create-nuxt-app
有npx
安装包,如果它尚未在全球计算机上安装。
安装完成后,create-nuxt-app
会询问一些关于要添加的额外工具的问题。对于本教程,做出了以下选择:
? Project name: client
? Programming language: JavaScript
? Package manager: Npm
? UI framework: Bootstrap Vue
? Nuxt.js modules: Axios
? Linting tools:
? Testing framework: None
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Server (Node.js hosting)
? Development tools:
这将使用选定的包管理器触发依赖项的安装。
导航到client
目录:
- cd client
让我们运行以下命令以在开发模式下启动应用程序:
npm run dev
开发服务器启动后,localhost:3000
前往查看应用程序:
现在,我们来看看目录的目录结构client
:
├── client
├── assets/
├── components/
├── layouts/
├── middleware/
├── node_modules/
├── pages/
├── plugins/
├── static/
└── store/
以下是这些目录的用途的细分:
- 资产– 包含未编译的文件,例如图像、CSS、Sass 和 JavaScript 文件。
- 组件– 包含 Vue.js 组件。
- Layouts – 包含应用程序的布局;布局用于更改页面的外观,并可用于多个页面。
- 中间件– 包含应用程序的中间件;中间件是在呈现页面之前运行的自定义函数。
- 页面– 包含应用程序的视图和路由。Nuxt.js 读取
.vue
此目录中的所有文件,并使用这些信息来创建应用程序的路由器。 - 插件– 包含要在根 Vue.js 应用程序实例化之前运行的 JavaScript 插件。
- 静态– 包含静态文件(不太可能更改的文件)并且所有这些文件都映射到应用程序的根目录,即
/
. - Store – 如果我们打算将 Vuex 与 Nuxt.js 一起使用,则包含存储文件。
目录中还有一个nuxt.config.js
文件client
,该文件包含 Nuxt.js 应用程序的自定义配置。
在我们继续之前,下载这个图像资产的 zip 文件,解压它,并将images
目录放在static
目录中。
页面结构
在本节中,我们将向目录中添加一些.vue
文件,pages
以便我们的应用程序有五个页面:
- 主页
- 所有食谱列表页面
- 单一配方查看页面
- 单一配方编辑页面
- 添加食谱页面
让我们将以下.vue
文件和文件夹添加到pages
目录中,这样我们就有了这个确切的结构:
├── pages/
├── recipes/
├── _id/
└── edit.vue
└── index.vue
└── add.vue
└── index.vue
└── index.vue
上面的文件结构将生成以下路由:
/
→ 处理pages/index.vue
/recipes/add
→ 处理pages/recipes/add.vue
/recipes/
→ 处理pages/recipes/index.vue
/recipes/{id}/
→ 处理pages/recipes/_id/index.vue
/recipes/{id}/edit
→ 处理pages/recipes/_id/edit.vue
一个.vue
下划线前缀的文件或目录将创建一个动态路由。这是我们的应用程序非常有用,因为它会很容易地显示基于其ID(例如,不同的食谱recipes/1/
,recipes/2/
等)。
创建主页
在 Nuxt.js 中,当您想要更改应用程序的外观和感觉时,布局非常有用。现在,Nuxt.js 应用程序的每个实例都带有默认布局,我们希望删除所有样式,以便它们不会干扰我们的应用程序。
打开layouts/default.vue
文件并将其替换为以下代码段:
<template>
<div>
<nuxt/>
</div>
</template>
<style>
</style>
让我们pages/index.vue
使用以下代码更新文件:
<template>
<header>
<div class="text-box">
<h1>La Recipes ?</h1>
<p class="mt-3">Recipes for the meals we love ❤️</p>
<nuxt-link class="btn btn-outline btn-large btn-info" to="/recipes">
View Recipes <span class="ml-2">→</span>
</nuxt-link>
</div>
</header>
</template>
<script>
export default {
head() {
return {
title: "Home page"
};
},
};
</script>
<style>
header {
min-height: 100vh;
background-image: linear-gradient(
to right,
rgba(0, 0, 0, 0.9),
rgba(0, 0, 0, 0.4)
),
url("/images/banner.jpg");
background-position: center;
background-size: cover;
position: relative;
}
.text-box {
position: absolute;
top: 50%;
left: 10%;
transform: translateY(-50%);
color: #fff;
}
.text-box h1 {
font-family: cursive;
font-size: 5rem;
}
.text-box p {
font-size: 2rem;
font-weight: lighter;
}
</style>
上面的代码<nuxt-link>
是一个 Nuxt.js 组件,可用于在页面之间导航。它与Vue Router 中的<router-link>
组件非常相似。
让我们启动前端开发服务器(如果它还没有运行):
- npm run dev
然后访问localhost:3000
并观察主页:
始终确保 Django 后端服务器始终在终端的另一个实例中运行,因为前端将很快开始与其通信以获取数据。
这个应用程序中的每个页面都是一个Vue
组件,Nuxt.js 提供了特殊的属性和功能,使应用程序的开发变得无缝。您可以在官方文档中找到所有这些特殊属性。
为了本教程,我们将使用其中两个函数:
head()
– 此方法用于<meta>
为当前页面设置特定标签。asyncData()
– 此方法用于在加载页面组件之前获取数据。然后将返回的对象与页面组件的数据合并。我们将在本教程的后面部分使用它。
创建食谱列表页面
让我们创建一个RecipeCard.vue
在components
目录中调用的 Vue.js 组件,并使用以下代码段更新它:
<template>
<div class="card recipe-card">
<img :src="recipe.picture" class="card-img-top" >
<div class="card-body">
<h5 class="card-title">{{ recipe.name }}</h5>
<p class="card-text">
<strong>Ingredients:</strong> {{ recipe.ingredients }}
</p>
<div class="action-buttons">
<nuxt-link :to="`/recipes/${recipe.id}/`" class="btn btn-sm btn-success">View</nuxt-link>
<nuxt-link :to="`/recipes/${recipe.id}/edit/`" class="btn btn-sm btn-primary">Edit</nuxt-link>
<button @click="onDelete(recipe.id)" class="btn btn-sm btn-danger">Delete</button>
</div>
</div>
</div>
</template>
<script>
export default {
props: ["recipe", "onDelete"]
};
</script>
<style>
.recipe-card {
box-shadow: 0 1rem 1.5rem rgba(0,0,0,.6);
}
</style>
上面的组件接受两个 props:
recipe
包含有关特定配方的信息的对象。- 一种
onDelete
方法将被触发每当在用户点击按钮来删除配方。
接下来,打开pages/recipes/index.vue
并使用以下代码段更新它:
<template>
<main class="container mt-5">
<div class="row">
<div class="col-12 text-right mb-4">
<div class="d-flex justify-content-between">
<h3>La Recipes</h3>
<nuxt-link to="/recipes/add" class="btn btn-info">Add Recipe</nuxt-link>
</div>
</div>
<template v-for="recipe in recipes">
<div :key="recipe.id" class="col-lg-3 col-md-4 col-sm-6 mb-4">
<recipe-card :onDelete="deleteRecipe" :recipe="recipe"></recipe-card>
</div>
</template>
</div>
</main>
</template>
<script>
import RecipeCard from "~/components/RecipeCard.vue";
const sampleData = [
{
id: 1,
name: "Jollof Rice",
picture: "/images/food-1.jpeg",
ingredients: "Beef, Tomato, Spinach",
difficulty: "easy",
prep_time: 15,
prep_guide:
"Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis, porro. Dignissimos ducimus ratione totam fugit officiis blanditiis exercitationem, nisi vero architecto quibusdam impedit, earum "
},
{
id: 2,
name: "Macaroni",
picture: "/images/food-2.jpeg",
ingredients: "Beef, Tomato, Spinach",
difficulty: "easy",
prep_time: 15,
prep_guide:
"Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis, porro. Dignissimos ducimus ratione totam fugit officiis blanditiis exercitationem, nisi vero architecto quibusdam impedit, earum "
},
{
id: 3,
name: "Fried Rice",
picture: "/images/banner.jpg",
ingredients: "Beef, Tomato, Spinach",
difficulty: "easy",
prep_time: 15,
prep_guide:
"Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis, porro. Dignissimos ducimus ratione totam fugit officiis blanditiis exercitationem, nisi vero architecto quibusdam impedit, earum "
}
];
export default {
head() {
return {
title: "Recipes list"
};
},
components: {
RecipeCard
},
asyncData(context) {
let data = sampleData;
return {
recipes: data
};
},
data() {
return {
recipes: []
};
},
methods: {
deleteRecipe(recipe_id) {
console.log(deleted `${recipe.id}`)
}
}
};
</script>
<style scoped>
</style>
让我们启动前端开发服务器(如果它还没有运行):
- npm run dev
然后,访问localhost:3000/recipes
并观察食谱列表页面:
从上图中,我们看到即使我们recipes
在组件的数据部分设置为空数组,也会出现三张配方卡。对此的解释是该方法asyncData
在页面加载之前执行并返回一个更新组件数据的对象。
现在,我们需要做的就是修改asyncData
方法以api
向 Django 后端发出请求并使用结果更新组件的数据。
在我们这样做之前,我们必须配置Axios
. 打开nuxt.config.js
文件并相应地更新它:
// add this Axios object
axios: {
baseURL: "http://localhost:8000/api"
},
注意:这假设您Axios
在使用create-nuxt-app
. 如果没有,则需要手动安装和配置modules
阵列。
现在,打开pages/recipes/index.vue
文件并将该<script>
部分替换为以下部分:
[...]
<script>
import RecipeCard from "~/components/RecipeCard.vue";
export default {
head() {
return {
title: "Recipes list"
};
},
components: {
RecipeCard
},
async asyncData({ $axios, params }) {
try {
let recipes = await $axios.$get(`/recipes/`);
return { recipes };
} catch (e) {
return { recipes: [] };
}
},
data() {
return {
recipes: []
};
},
methods: {
async deleteRecipe(recipe_id) {
try {
await this.$axios.$delete(`/recipes/${recipe_id}/`); // delete recipe
let newRecipes = await this.$axios.$get("/recipes/"); // get new list of recipes
this.recipes = newRecipes; // update list of recipes
} catch (e) {
console.log(e);
}
}
}
};
</script>
[...]
在上面的代码中,asyncData()
接收一个名为 的对象context
,我们对其进行解构以获取$axios
。您可以在官方文档中查看 的所有属性context
。
我们将其包裹asyncData()
在一个try...catch
块中,因为我们想防止在后端服务器未运行且 Axios 无法检索数据时发生的错误。每当发生这种情况时,recipes
只需将其设置为空数组即可。
这行代码:
let recipes = await $axios.$get("/recipes/")
是一个较短的版本:
let response = await $axios.get("/recipes")
let recipes = response.data
该deleteRecipe()
方法删除一个特定的配方,从 Django 后端获取最新的配方列表,最后更新组件的数据。
我们现在可以启动前端开发服务器(如果它尚未运行),我们将看到配方卡现在正在填充来自 Django 后端的数据。
为此,必须运行 Django 后端服务器,并且必须有一些数据(从管理界面输入)可用于 Recipe 项目。
- npm run dev
让我们参观localhost:3000/recipes
:
您还可以尝试删除配方项目并观察它们相应地更新。
添加新食谱
正如我们已经讨论过的,我们希望能够从应用程序的前端添加新配方,因此打开pages/recipes/add/
文件并使用以下代码段更新它:
<template>
<main class="container my-5">
<div class="row">
<div class="col-12 text-center my-3">
<h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2>
</div>
<div class="col-md-6 mb-4">
<img
v-if="preview"
class="img-fluid"
style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);"
:src="preview"
alt
>
<img
v-else
class="img-fluid"
style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);"
src="@/static/images/placeholder.png"
>
</div>
<div class="col-md-4">
<form @submit.prevent="submitRecipe">
<div class="form-group">
<label for>Recipe Name</label>
<input type="text" class="form-control" v-model="recipe.name">
</div>
<div class="form-group">
<label for>Ingredients</label>
<input v-model="recipe.ingredients" type="text" class="form-control">
</div>
<div class="form-group">
<label for>Food picture</label>
<input type="file" name="file" @change="onFileChange">
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for>Difficulty</label>
<select v-model="recipe.difficulty" class="form-control">
<option value="Easy">Easy</option>
<option value="Medium">Medium</option>
<option value="Hard">Hard</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for>
Prep time
<small>(minutes)</small>
</label>
<input v-model="recipe.prep_time" type="number" class="form-control">
</div>
</div>
</div>
<div class="form-group mb-3">
<label for>Preparation guide</label>
<textarea v-model="recipe.prep_guide" class="form-control" rows="8"></textarea>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
</main>
</template>
<script>
export default {
head() {
return {
title: "Add Recipe"
};
},
data() {
return {
recipe: {
name: "",
picture: "",
ingredients: "",
difficulty: "",
prep_time: null,
prep_guide: ""
},
preview: ""
};
},
methods: {
onFileChange(e) {
let files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.recipe.picture = files[0];
this.createImage(files[0]);
},
createImage(file) {
// let image = new Image();
let reader = new FileReader();
let vm = this;
reader.onload = e => {
vm.preview = e.target.result;
};
reader.readAsDataURL(file);
},
async submitRecipe() {
const config = {
headers: { "content-type": "multipart/form-data" }
};
let formData = new FormData();
for (let data in this.recipe) {
formData.append(data, this.recipe[data]);
}
try {
let response = await this.$axios.$post("/recipes/", formData, config);
this.$router.push("/recipes/");
} catch (e) {
console.log(e);
}
}
}
};
</script>
<style scoped>
</style>
在 中submitRecipe()
,一旦发布了表单数据并成功创建了配方,应用程序就会重定向到/recipes/
使用this.$router
.
创建单一配方视图页面
让我们创建允许用户查看单个 Recipe 项的视图,打开/pages/recipes/_id/index.vue
文件并粘贴以下代码段:
<template>
<main class="container my-5">
<div class="row">
<div class="col-12 text-center my-3">
<h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2>
</div>
<div class="col-md-6 mb-4">
<img
class="img-fluid"
style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);"
:src="recipe.picture"
alt
>
</div>
<div class="col-md-6">
<div class="recipe-details">
<h4>Ingredients</h4>
<p>{{ recipe.ingredients }}</p>
<h4>Preparation time ⏱</h4>
<p>{{ recipe.prep_time }} mins</p>
<h4>Difficulty</h4>
<p>{{ recipe.difficulty }}</p>
<h4>Preparation guide</h4>
<textarea class="form-control" rows="10" v-html="recipe.prep_guide" disabled />
</div>
</div>
</div>
</main>
</template>
<script>
export default {
head() {
return {
title: "View Recipe"
};
},
async asyncData({ $axios, params }) {
try {
let recipe = await $axios.$get(`/recipes/${params.id}`);
return { recipe };
} catch (e) {
return { recipe: [] };
}
},
data() {
return {
recipe: {
name: "",
picture: "",
ingredients: "",
difficulty: "",
prep_time: null,
prep_guide: ""
}
};
}
};
</script>
<style scoped>
</style>
我们介绍方法中params
看到的key asyncData()
。在这种情况下,我们使用params
来获取ID
我们想要查看的配方的 。我们params
从 中提取URL
并预取其数据,然后将其显示在页面上。
我们可以在 Web 浏览器上观察到单个 Recipe 项。
创建单一配方编辑页面
我们需要创建允许用户编辑和更新单个 Recipe 项目的视图,因此打开/pages/recipes/_id/edit.vue
文件并粘贴以下代码段:
<template>
<main class="container my-5">
<div class="row">
<div class="col-12 text-center my-3">
<h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2>
</div>
<div class="col-md-6 mb-4">
<img v-if="!preview" class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" :src="recipe.picture">
<img v-else class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" :src="preview">
</div>
<div class="col-md-4">
<form @submit.prevent="submitRecipe">
<div class="form-group">
<label for>Recipe Name</label>
<input type="text" class="form-control" v-model="recipe.name" >
</div>
<div class="form-group">
<label for>Ingredients</label>
<input type="text" v-model="recipe.ingredients" class="form-control" name="Ingredients" >
</div>
<div class="form-group">
<label for>Food picture</label>
<input type="file" @change="onFileChange">
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for>Difficulty</label>
<select v-model="recipe.difficulty" class="form-control" >
<option value="Easy">Easy</option>
<option value="Medium">Medium</option>
<option value="Hard">Hard</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for>
Prep time
<small>(minutes)</small>
</label>
<input type="text" v-model="recipe.prep_time" class="form-control" name="Ingredients" >
</div>
</div>
</div>
<div class="form-group mb-3">
<label for>Preparation guide</label>
<textarea v-model="recipe.prep_guide" class="form-control" rows="8"></textarea>
</div>
<button type="submit" class="btn btn-success">Save</button>
</form>
</div>
</div>
</main>
</template>
<script>
export default {
head(){
return {
title: "Edit Recipe"
}
},
async asyncData({ $axios, params }) {
try {
let recipe = await $axios.$get(`/recipes/${params.id}`);
return { recipe };
} catch (e) {
return { recipe: [] };
}
},
data() {
return {
recipe: {
name: "",
picture: "",
ingredients: "",
difficulty: "",
prep_time: null,
prep_guide: ""
},
preview: ""
};
},
methods: {
onFileChange(e) {
let files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.recipe.picture = files[0]
this.createImage(files[0]);
},
createImage(file) {
let reader = new FileReader();
let vm = this;
reader.onload = e => {
vm.preview = e.target.result;
};
reader.readAsDataURL(file);
},
async submitRecipe() {
let editedRecipe = this.recipe
if (editedRecipe.picture.name.indexOf("http://") != -1){
delete editedRecipe["picture"]
}
const config = {
headers: { "content-type": "multipart/form-data" }
};
let formData = new FormData();
for (let data in editedRecipe) {
formData.append(data, editedRecipe[data]);
}
try {
let response = await this.$axios.$patch(`/recipes/${editedRecipe.id}/`, formData, config);
this.$router.push("/recipes/");
} catch (e) {
console.log(e);
}
}
}
};
</script>
<style scoped>
</style>
在上面的代码中,该submitRecipe()
方法有一个条件语句,其目的是在图片未更改的情况下从要提交的数据中删除已编辑的 Recipe 项目的图片。
配方项目更新后,应用程序将重定向到配方列表页面 — /recipes/
。
设置过渡
该应用程序功能齐全,但是,我们可以通过添加过渡使其看起来更平滑,这允许我们在给定的持续时间内平滑地更改 CSS 属性值(从一个值到另一个值)。
We will set up transitions in the nuxt.config.js
file. By default, the transition name is set to page
, which means that the transitions we define will be active on all pages.
Let’s include the styling for the transition. Create a directory called css
in the assets
directory and add a transitions.css
file within. Now open the transitions.css
file and paste in the snippet below:
.page-enter-active,
.page-leave-active {
transition: opacity .3s ease;
}
.page-enter,
.page-leave-to {
opacity: 0;
}
Open the nuxt.config.js
file and update it accordingly to load the CSS file we just created:
/*
** Global CSS
*/
css: [
'~/assets/css/transitions.css', // update this
],
Save your changes and open the application in your browser:
Now, our application will change frames on each navigation in a sleek way.
Conclusion
在本文中,我们首先了解客户端和服务器端呈现的应用程序之间的差异。我们继续学习什么是通用应用程序,最后,我们看到了如何使用 Nuxt.js 和 Django 构建通用应用程序。