Appearance
实践:RealWorld(Vue3.0 + TypeScript) 案例
注意
目前项目仅完成了基本的ts支持、首页文章列表及登录,基本内容已覆盖
技术栈:
- Vue.js 3
- Vue Router
- Vuex
- TypeScript
- axios
项目链接
创建项目
- 安装vue-cli,
npm i -g @vue/cli
,确认版本vue --version
- 创建项目
vue create 3-5-realworld
bash
? Please pick a preset: (Use arrow keys)
Default ([Vue 2] babel, eslint)
Default (Vue 3 Preview) ([Vue 3] babel, eslint)
Manually select features(自定义特性)
? Check the features needed for your project:
(*) Choose Vue version
(*) Babel
(*) TypeScript
( ) Progressive Web App (PWA) Support
(*) Router
(*) Vuex
( ) CSS Pre-processors
>(*) Linter / Formatter(格式化校验)
( ) Unit Testing
( ) E2E Testing
? Choose a version of Vue.js that you want to start the project with
2.x
3.x (Preview)
# 是否使用class风格的语法
? Use class-style component syntax? (y/N) N
# 是否使用babel和typescript结合到一起(用于现代模式、自动polyfill,编译转换jsx)
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? (Y/n) Y
# 是否使用history路由模式?
? Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) n
# 选择eslint校验规范
? Pick a linter / formatter config: (Use arrow keys)
ESLint with error prevention only
ESLint + Airbnb config
ESLint + Standard config
ESLint + Prettier
TSLint (deprecated)
# 啥时候进行代码格式校验
? Pick additional lint features:
(*) Lint on save (保存的时候)
>(*) Lint and fix on commit (代码提交的时候)
# 配置信息保存到哪里?
? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys)
In dedicated config files (单独文件,推荐)
In package.json (合并到package.json中)
是否保存成预置模板?
? Save this as a preset for future projects? (y/N) n
- 进入项目打开vscode
bash
cd .\3-5-vue3-realworld\
code .
观察Vue3项目
相同点:
- 目录结构是一样的
- API的用法大体是相同的
不同点:
- router
js
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import Home from '../views/Home.vue'
// 这里是路由表的类型RouteRecordRaw
const routes: Array<RouteRecordRaw= [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
// which is lazy-loaded when the route is visited.
component: () =import(/* webpackChunkName: "about" */ '../views/About.vue')
}
]
// 之前创建router实例是这样new VueRouter()
const router = createRouter({
// 这里是使用history模式
history: createWebHashHistory(),
routes
})
export default router
// 其他的都是一样的。
- vuex
js
import { createStore } from 'vuex'
// 以前创建store实例 new Vuex.Store({ state: { xxx }})
// 现在使用createStore去创建
export default createStore({
state: {
},
mutations: {
},
actions: {
},
modules: {
}
})
- Home.vue
html
<template>
<div class="home">
<img alt="Vue logo" src="../assets/logo.png">
<HelloWorld msg="Welcome to Your Vue.js + TypeScript App"/>
</div>
</template>
<!--使用ts,标注lang-->
<script lang="ts">
// 里面使用 defineComponent 定义组件
import { defineComponent } from 'vue'
import HelloWorld from '@/components/HelloWorld.vue' // @ is an alias to /src
// 里面如果使用options API选项是一样的
export default defineComponent({
name: 'Home',
components: {
HelloWorld
},
data () {},
methods: {},
mounted () {}
})
</script>
- main.ts
js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
// 以前创建 vue 根实例用 new Vue(),现在用 createApp
createApp(App)
.use(store) // use是挂载路由实例
.use(router)
.mount('#app') // 作用到根节点,这句代码要放在最最后
也可以使用
ts
const app = createApp(App)
app.use(store)
app.use(router)
// app.provide('属性名', 属性值)
// 注册全局组件
// app.component()
// 注册全局指令
// app.directive()
app.mount('#app')
- shims-vue.d.ts
typeScript类型声明文件,如果加载.vue
文件,其类型component。
js
/* eslint-disable */
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
- ts.config.json
typeScript编译相关的文件,要了解一下
配置模板
- 下载样式 main.css 到 放public/css/main.css 中,在 public/index.html 中加载样式文件。
html
<!-- Import Ionicon icons & Google Fonts our Bootstrap theme relies on -->
<link href="https://cdn.jsdelivr.net/npm/ionicons@2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css">
<link
href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
rel="stylesheet" type="text/css">
<!-- Import the custom Bootstrap 4 theme from our hosted CDN -->
<link rel="stylesheet" href="/css/main.css">
- src目录下创建layout文件夹,创建AppHeader.vue文件
html
<template>
<nav class="navbar navbar-light">
<div class="container">
<a class="navbar-brand" href="index.html">conduit</a>
<ul class="nav navbar-nav pull-xs-right">
<li class="nav-item">
<!-- Add "active" class when you're on that page" -->
<a class="nav-link active" href="">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="">
<i class="ion-compose"></i> New Post
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="">
<i class="ion-gear-a"></i> Settings
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="">Sign up</a>
</li>
</ul>
</div>
</nav>
</template>
- 同目录下创建AppFooter.vue文件
html
<template>
<footer>
<div class="container">
<a href="/" class="logo-font">conduit</a>
<span class="attribution">
An interactive learning project from <a href="https://thinkster.io">Thinkster</a>. Code & design licensed under MIT.
</span>
</div>
</footer>
</template>
- 同目录下创建AppLayout.vue文件
html
<template>
<!-- Vue3 的模板不需要根节点 -->
<AppHeader/>
<AppFooter/>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import AppHeader from './AppHeader.vue' // 使用了ts 需要加上模块后缀 .vue
import AppFooter from './AppFooter.vue'
export default defineComponent({
components: {
AppHeader,
AppFooter
}
})
</script>
<style>
</style>
- 把App.vue中的默认样式去掉,只保留一个跟路由出口
html
<template>
<!-- 根路由出口 -->
<router-view/>
</template>
- 在src/views/home/index.vue文件中添加首页模板
html
<template>
<div class="home-page">
<div class="banner">
<div class="container">
<h1 class="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div class="container page">
<div class="row">
<div class="col-md-9">
<div class="feed-toggle">
<ul class="nav nav-pills outline-active">
<li class="nav-item">
<a class="nav-link disabled" href="">Your Feed</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="">Global Feed</a>
</li>
</ul>
</div>
<div class="article-preview">
<div class="article-meta">
<a href="profile.html"
><img src="http://i.imgur.com/Qr71crq.jpg"
/></a>
<div class="info">
<a href="" class="author">Eric Simons</a>
<span class="date">January 20th</span>
</div>
<button class="btn btn-outline-primary btn-sm pull-xs-right">
<i class="ion-heart"></i29
</button>
</div>
<a href="" class="preview-link">
<h1>How to build webapps that scale</h1>
<p>This is the description for the post.</p>
<span>Read more...</span>
</a>
</div>
<div class="article-preview">
<div class="article-meta">
<a href="profile.html"
><img src="http://i.imgur.com/N4VcUeJ.jpg"
/></a>
<div class="info">
<a href="" class="author">Albert Pai</a>
<span class="date">January 20th</span>
</div>
<button class="btn btn-outline-primary btn-sm pull-xs-right">
<i class="ion-heart"></i32
</button>
</div>
<a href="" class="preview-link">
<h1>
The song you won't ever stop singing. No matter how hard you
try.
</h1>
<p>This is the description for the post.</p>
<span>Read more...</span>
</a>
</div>
</div>
<div class="col-md-3">
<div class="sidebar">
<p>Popular Tags</p>
<div class="tag-list">
<a href="" class="tag-pill tag-default">programming</a>
<a href="" class="tag-pill tag-default">javascript</a>
<a href="" class="tag-pill tag-default">emberjs</a>
<a href="" class="tag-pill tag-default">angularjs</a>
<a href="" class="tag-pill tag-default">react</a>
<a href="" class="tag-pill tag-default">mean</a>
<a href="" class="tag-pill tag-default">node</a>
<a href="" class="tag-pill tag-default">rails</a>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
- 在src/views/login/index.vue文件中添加登录模板
html
<template>
<div class="auth-page">
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">Sign up</h1>
<p class="text-xs-center">
<a href="">Have an account?</a>
</p>
<ul class="error-messages">
<li>That email is already taken</li>
</ul>
<form>
<fieldset class="form-group">
<input
class="form-control form-control-lg"
type="text"
placeholder="Your Name"
/>
</fieldset>
<fieldset class="form-group">
<input
class="form-control form-control-lg"
type="text"
placeholder="Email"
/>
</fieldset>
<fieldset class="form-group">
<input
class="form-control form-control-lg"
type="password"
placeholder="Password"
/>
</fieldset>
<button class="btn btn-lg btn-primary pull-xs-right">
Sign up
</button>
</form>
</div>
</div>
</div>
</div>
</template>
- 在router/index.ts中写路由配置
js
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
const routes: Array<RouteRecordRaw= [
{
path: '/',
component: () =import('@/layout/AppLayout.vue'),
children: [
{
path: '', // 默认子组件
name: 'home',
component: () =import('@/views/home/index.vue')
},
{
path: 'login',
name: 'login',
component: () =import('@/views/login/index.vue')
}
]
}
]
// new VueRouter()
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router
- 在AppLayout.vue中添加子路由出口
html
<template>
<!-- Vue3 的模板不需要根节点 -->
<AppHeader/>
<!-- 子路由出口 -->
<router-view/>
<AppFooter/>
</template>
- 输入
npm run serve
就可以浏览页面了,http://localhost:8080/#/
是首页,http://localhost:8080/#/login
是登录页面。
封装请求模块
- 安装axios
npm i axios
,axios对ts的支持很友好
npm小知识
npm i xxx 在npm 5 之后默认会加 --save
--save 和 -S 是一个东西
npm 5 之前不会保存依赖信息,npm 5 之后会自动写入 package.json 的 dependencies ,生产依赖,yarn add 也是默认加入生产依赖
--save-dev 是保存到开发依赖,简写是 -D
如果要保存在devDependencies 中,必须加上 --save-dev 或者 -D
安装全局包(命令行工具),需要使用 --global,简写是 -g, 全局包一般都是工具类的,比如 serve、gulp、http-server、Vue CLI ,安装一次就不用重复安装了
全局包一般都提供一个命令给你使用。
也可以把vue和jquery安装到全局,但是么有意义,项目中也不能直接引用。
- 配置环境变量,根目录下创建
.env.production
bash
VUE_APP_API_BASEURL=https://conduit.productionready.io/
根目录下创建.env.development
bash
VUE_APP_API_BASEURL=https://conduit.productionready.io/
参考:模式和环境变量
- 在src目录下创建utils/request.ts
js
import axios from 'axios'
export const request = axios.create({
// VUE_APP 开头才能在应用中读取到 vue的webpack只会把这种开头的进行读取
baseURL: process.env.VUE_APP_API_BASEURL
})
// 请求拦截器
// 响应拦截器
- 在src目录下创建api/user.ts
ts
import { request } from '../utils/request'
// 设置一下login中data的类型,不然会报错,如果传入的data不是这个类型,就会报错
// 定义接口 登录输入类型
interface LoginInput {
user: {
email: string,
password: string
}
}
// 定义接口,登录返回类型User
interface User {
email: string
token: string
image: string
bio: string
username: string
}
// 给返回值也定义类型,不然返回的内容是any,没有类型推断
// 定义接口,登录返回类型
interface LoginPayload {
// 里面的user是User类型
user: User
}
// 输入的参数data定义类型LoginInput
export const login = (data: LoginInput) => {
// 调用post方法的时候设置一个泛型,把返回的内容设置成LoginPayload类型
return request.post<LoginPayload>('/api/users/login', data)
}
用户登录
- 在login/index.vue中写登录逻辑
html
<script lang="ts">
import { defineComponent, reactive } from 'vue'
import { login } from '@/api/user'
export default defineComponent({
name: 'loginPage',
setup () {
const user = reactive({ // 支持类型推断
email: '',
password: ''
})
// 提交方法
const handleSubmit = async () => {
try {
const { data } = await login({ user })
console.log(data)
} catch (err) {
console.log('登录失败', err)
}
}
// 返回的内容可以在模板中绑定使用
return {
user,
handleSubmit
}
}
})
</script>
- 在模板中修改
html
<!--表单提交-->
<form @submit.prevent="handleSubmit">
<!-- <fieldset class="form-group">
<input
class="form-control form-control-lg"
type="text"
placeholder="Your Name"
/>
</fieldset> -->
<fieldset class="form-group">
<!--绑定email-->
<input
v-model="user.email"
class="form-control form-control-lg"
type="text"
placeholder="Email"
/>
</fieldset>
<!--绑定password-->
<fieldset class="form-group">
<input
v-model="user.password"
class="form-control form-control-lg"
type="password"
placeholder="Password"
/>
</fieldset>
<button class="btn btn-lg btn-primary pull-xs-right">
Sign up
</button>
</form>
- 启动服务可以看到终端有报错
ERROR
error Expected a semicolon @typescript-eslint/member-delimiter-style
去.eslintrc.js中配置一下rules '@typescript-eslint/member-delimiter-style': 'off'
再次启动服务,简单输入邮箱和密码,可以登录成功,可以拿到返回的信息,可以看到后台返回的东西比我们定义的多,那需要我们补齐,这里先忽略掉。
登录成功之后跳转首页,这里不能使用this,那么这样使用
js
...
// 导入vue-router的useRouter模块直接使用
import { useRouter } from 'vue-router'
export default defineComponent({
name: 'loginPage',
setup () {
// 获得路由
const router = useRouter()
...
const handleSubmit = async () => {
try {
const { data } = await login({ user })
console.log(data)
// 登录成功之后跳转到首页
router.push({
name: 'home'
})
} catch (err) {
...
}
}
...
}
})
- 进行组件的提取
js
// 登录逻辑单独定义一个函数
const useLogin = () => {
// 获得路由
const router = useRouter()
const user = reactive({ // 支持类型推断
email: '',
password: ''
})
const handleSubmit = async () => {
try {
const { data } = await login({ user })
console.log(data)
// 登录成功之后跳转到首页
router.push({
name: 'home'
})
} catch (err) {
console.log('登录失败', err)
}
}
return {
user,
handleSubmit
}
}
export default defineComponent({
name: 'loginPage',
setup () {
return {
// 组合到当前组件中
...useLogin()
}
}
})
让Vuex支持ts
- 在store/index.ts中重新引入代码支持ts
js
import { InjectionKey } from 'vue'
import { createStore, Store } from 'vuex'
// define your typings for the store state 定义state类型
export interface State {
count: number
}
// define injection key 定义注入的key
export const key: InjectionKey<Store<State>> = Symbol()
// 创建store容器,容器中通过泛型只听State的类型
export const store = createStore<State>({
state: { // 必须符合泛型参数 State 类型要求
count: 0
}
})
- 在入口模块main.ts中把key导入并挂载
js
import { store, key } from './store'
createApp(App)
.use(store, key) // 把key传入
- 去login/index.vue中不用key是没有类型推断的,
js
import { useStore } from 'vuex'
const useLogin = () => {
// 没有类型推断
const store = useStore()
...
}
需要将key导入
js
import { useStore } from 'vuex'
import { key } from '@/store'
const useLogin = () => {
// const store: Store<State> 有了类型推断
const store = useStore(key)
...
}
- 如果觉得这样比较麻烦,可以在store/index.ts中进行进一步的封装
js
import { createStore, Store, useStore as baseUseStore } from 'vuex'
export const key: ...
...
export function useStore () {
// 这里传入那login/index.vue就不用传key了
return baseUseStore(key)
}
这样在login/index.vue中就可以直接用store的useStore,且不用传key
js
import { useStore } from '@/store'
const useLogin = () => {
// 获得路由
const router = useRouter()
const store = useStore()
身份认证
设置登录完成之后保存登录信息
- api/user.ts中将User类型导出
js
// 定义接口,登录返回类型User
export interface User {
email: string
token: string
image: string
bio: string
username: string
}
- 在store/index.ts中设置值
js
import { InjectionKey } from 'vue'
import { createStore, Store } from 'vuex'
// 将api中设置的User类型导入
import { User } from '@/api/user'
export interface State {
count: number
// 可以置空
user: User | null
}
export const key: InjectionKey<Store<State>> = Symbol()
export const store = createStore<State>({
state: {
count: 0,
// 默认从本地存储获取,如果没有给一个字符串的null,不然会有类型错误
user: JSON.parse(window.localStorage.getItem('user') || 'null')
},
mutations: {
// 设置user
setUser (state, user: User) {
state.user = user
// 持久化到本地存储
window.localStorage.setItem('user', JSON.stringify(state.user))
}
}
})
- 在login/index.vue中登录完成之后进行存储。
js
const handleSubmit = async () => {
try {
const { data } = await login({ user })
// 成功之后将user传递进去
store.commit('setUser', data.user)
// 登录成功之后跳转到首页
router.push({
name: 'home'
})
} catch (err) {
console.log('登录失败', err)
}
}
npm run serve
的时候会有报错
ERROR
error Expected Symbol to have a description symbol-description
这是因为store/index.ts中key定义了一个Symbol类型,这个根据ES6语法里面要传一个描述符字符串,可以随便加一个名字
js
export const key: InjectionKey<Store<State>> = Symbol('vue')
也可以不加直接在.eslintrc.js的rules里面关闭检查
js
'symbol-description': 'off'
- 这个时候点击登录,可以看到已经保存到localStorage中了。
- 下面请求的时候,如果要加验证的话,需要在request.ts中添加拦截器,这样就可以了。
js
// 请求拦截器
request.interceptors.request.use(config => {
const user = store.state.user
if (user) {
config.headers.Authorization = `Token ${user.token}`
}
return config
}, err => {
return Promise.reject(err)
})
获取文章列表
- 创建api/article.ts,里面写
ts
import { request } from '../utils/request'
// 入参,可选
interface ArticlesParams {
tag?: string
author?: string
favorited?: string
limit?: number
offset?: number
}
// 返回值,只要有对象的就提出来,方便重用
interface ArticleAuthor {
username: string
bio: string
image: string
following: boolean
}
// 导出,为了首页初始化数据的时候使用
export interface Article {
slug:string
title: string
description: string
body: string
tagList: string[]
createdAt: string
updatedAt: string
favorited: boolean
favoritesCount: number
author: ArticleAuthor
}
// articles里面是Article类型的数组
interface ArticlesPayload {
articles: Article[]
articlesCount: number
}
// 入参和返回值都设置了类型
export const getArticles = (params?: ArticlesParams) => {
return request.get<ArticlesPayload>('/api/articles')
}
- 在home/index.vue中写请求文章列表代码
html
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
import { Article, getArticles } from '@/api/article'
const useArticles = () => {
// 本地创建了响应式数据,并把Article类型导入,初始是一个空数组,里面的值应该是Article类型
const articles = ref<Article[]>([])
// 初始的articlesCount应该是响应式的,初始为0
const articlesCount = ref(0)
// 加载文章列表并赋值
const loadArticles = async () => {
const { data } = await getArticles()
articles.value = data.articles
articlesCount.value = data.articlesCount
}
// 在DOM初始化完执行
onMounted(loadArticles)
return {
articles,
articlesCount
}
}
export default defineComponent({
setup () {
return {
...useArticles()
}
}
})
</script>
- 修改模板,找到类名是article-preview的div,删除一个之后渲染文章列表
html
<!--循环列表-->
<div class="article-preview" v-for="article in articles" :key="article.slug">
<div class="article-meta">
<!--下载头像,修改路由跳转-->
<router-link to="/"
><img :src="article.author.image"
/></router-link>
<div class="info">
<!--作者姓名,修改路由跳转-->
<router-link to="/" class="author">{{ article.author.username }}</router-link>
<!--创作时间-->
<span class="date">{{ article.createdAt }}</span>
</div>
<!--点赞样式,数量-->
<button class="btn btn-outline-primary btn-sm pull-xs-right"
:class="{
active: article.favorited
}">
<i class="ion-heart"></i> {{ article.favoritesCount }}
</button>
</div>
<!--文章跳转、标题、描述-->
<router-link to="/" class="preview-link">
<h1>{{ article.title }}</h1>
<p>{{ article.description }}</p>
<span>Read more...</span>
</router-link>
</div>
- 启动服务,可以看到内容已经渲染上去了。
- 例子就先做到这里,首页和登录基本完成。