项目概述与搭建
项目概述:使用Vue.js
实现多人共享博客
吹逼:一个基于 Vue3 + Pinia + TypeScript + Vite2 + ant-design-vue 完整技术路线开发的项目,秒级开发更新启动、新的vue3 composition api 结合 setup纵享丝滑般的开发体验、全新的 pinia状态管理器和优秀的设计体验(1k的size)、antd无障碍过渡使用UI组件库 ant-design-vue、安全高效的 TypeScript 类型支持
- 该项目使用 Vue3 实现了一个在线博客分享的平台。包含首页、用户文章列表、个人管理等页面,实现了登录、注册、编辑、发布等功能。
- 项目使用 Vite2 为编译打包工具创建项目模版。之后考虑升级到
Vite3
- 使用 SCSS 作 CSS 预处理,使用 Grid 作页面布局,使用 Ant-Design-Vue 作UI组件
- 通过 Vue Router 实现路由的跳转、异步加载、权限验证等
- 通过 Pinia 实现状态管理
- 用 Axios 获取数据,并对接口进行了封装
介绍文字
- 一个基于 Vue 3 / TypeScript / SCSS 和 Vite 2 构建的单页面应用
- 这是我从自己记录学习笔记的需求出发,设计出的极简博客应用, 特点是使用
markdown
语法快速记录技术博客,并且实现不同用户博客共享
- 主要功能包括 前端页面的登录、注册、首页文章列表展示、博客详情展示、博客创建、发布、编辑和删除(
markdown
格式)、我的页面和其他用户文章列表展示等。 实现的步骤已总结为 博客
- 使用了开源的后端API接口,并用 Axios 封装请求,实现对应的 拦截器 和 JWT 身份认证功能 并且使用了 ApiPost 接口测试工具,导出详细的 接口文档 ,让我对鉴权和接口封装有了一定的认识和实践
- 该项目使用基于
Composition API
实现的 Vue3 Hooks
,让我对 hooks
有了深刻的理解和应用。 项目中的 hooks,代替之前项目中使用的 mixin 写法 解决mixin缺点
- 尝试完全使用
TSX
和 CSS module
语法代替 模板SFC
,将使用心得总结为博客
- 使用了
Pinia
作为统一的状态管理,对组件通信有了 新的理解
项目信息
- 设计稿
- 项目预览地址: https://xmasuhai.xyz/vite-share-blog-website/#/
- 项目功能包含:
- 首页:展示所有作者设置到首页的博客列表
- 详情:展示博客详情
- 登录、注册: 用户登录注册
- 用户页面: 展示某个用户的所有博客列表
- 我的: 展示个人主页
- 编辑、删除、创建博客,博客是以
markdown
格式编写
- 项目使用
Vue.js
技术栈的 单页面应用,版本为Vue 3.2
Vue
全家桶:
- 脚手架
vite2.X
代替vue-cli
和webpack
- 路由
vue-router4
- 状态管理
pinia
代替vuex4
vitest
- 使用
JSX
语法与组合式API
es6
语法
- 封装
axios
- UI框架采用
ant-design-vue
axios
及api接口封装、jwt等
Hooks
库使用vueUse
- 后端使用饥人谷的共享博客后端接口,共享博客接口文档
- 后期自己搭建服务器,使用
Node.js
编写后端代码
大纲链接 §
[toc]
项目搭建 ⇧
初始化项目 ⇧
Node.js@16
pnpm
代替yarn
- 在安装时项目时
- 如果使用
pnpm create vite
就一直使用 pnpm
- 如果使用
yarn create vite
就一直使用 yarn
使用vite
搭建vue-ts
项目 ⇧
1
2
3
4
|
cd ~
# yarn create vite <project-name>
# pnpm create vite <project-name>
pnpm create vite share-blog
|
1
2
3
4
5
6
|
# 安装依赖
pnpm install
# 开发预览
pnpm dev
# 打包
pnpm build
|
使用 defineConfig 工具函数,这样不用 jsdoc 注解也可以获取类型提示:
- Vite 直接支持 TS 配置文件。可以在
vite.config.ts
中使用 defineConfig
工具函数
1
2
3
4
5
|
import { defineConfig } from 'vite'
export default defineConfig({
// ...
})
|
配置 ip 访问项目 ⇧
- vite 启动后出现
Network: use --host to expose
1
2
3
4
|
vite v2.7.13 dev server running at:
> Local: http://localhost:3000/
> Network: use `--host` to expose
|
- 是因为
IP
没有做配置
- 所以不能从
IP
启动
- 需要在
vite.config.js
做相应配置
- 在
vite.config.js
中添加 server.host
为 0.0.0.0
1
2
3
4
5
6
7
8
9
10
|
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0'
}
})
|
1
2
3
4
5
6
7
8
9
|
vite v2.7.13 dev server running at:
> Network: http://172.27.128.1:3000/
> Network: http://192.168.0.109:3000/
> Local: http://localhost:3000/
> Network: http://172.25.176.1:3000/
> Network: http://172.25.224.1:3000/
ready in 692ms.
|
vite
创建的初始项目目录 ⇧
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
share-blog-vite
├─ .gitignore
├─ index.html
├─ package.json
├─ pnpm-lock.yaml
├─ public
│ └─ favicon.ico
├─ README.md
├─ src
│ ├─ App.vue
│ ├─ assets
│ │ └─ logo.png
│ ├─ components
│ │ └─ HelloWorld.vue
│ ├─ env.d.ts
│ └─ main.ts
├─ tsconfig.json
├─ vite.config.ts
└─ yarn.lock
|
vite
创建代码简单分析 ⇧
- 可以看到
index.html
中的入口文件导入方式 <script type="module" src="/src/main.ts"></script>
的类型为type="module"
支持模块化导入
- 框架代码中
main.ts
中就可以使用ES6 Module
方式组织代码,不用再转译为低版本的模块化方式
*区别于使用vue/cli
搭建vue-ts
项目
- 使用古老的
@vue/cli
搭建vue-ts
项目,不推荐使用pnpm
- 使用
yarn
即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
cd ~
vue create share-world
# Choose # Manually select features
----
# Choose Choose Vue version
(*) Babel
(*) TypeScript
(*) Progressive Web App (PWA) Support
(*) Router
(*) Vuex
(*) CSS Pre-processors
(*) Linter / Formatter
>(*) Unit Testing
( ) E2E Testing
----
# Choose # 3.x
----
# Choose # y
----
# Choose # y
----
# Choose # n
----
# Choose # Sass/SCSS (with dart-sass)
----
# Choose # ESLint with error prevention only
----
# Choose # ( ) Lint on save
>(*) Lint and fix on commit
----
# Choose # Jest
----
# Choose # In dedicated config files
----
# Choose # N
cd share-world
yarn serve
|
- 注意,使用
@vue/cli
构建的项目,许多依赖已经停止维护deprecated
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
λ pnpm install
WARN deprecated eslint-loader@2.2.1: This loader has been deprecated. Please use eslint-webpack-plugin
WARN deprecated html-webpack-plugin@3.2.0: 3.x is no longer supported
WARN deprecated @hapi/joi@15.1.1: Switch to 'npm install joi'
WARN deprecated request@2.88.2: request has been deprecated, see https://github.com/request/request/issues/3142
WARN deprecated chokidar@2.1.8: Chokidar 2 does not receive security updates since 2019. Upgrade to chokidar 3 with 15x
fewer dependencies
WARN deprecated @hapi/bourne@1.3.2: This version has been deprecated and is no longer supported or maintained
WARN deprecated @hapi/address@2.1.4: Moved to 'npm install @sideway/address'
WARN deprecated @hapi/topo@3.1.6: This version has been deprecated and is no longer supported or maintained
WARN deprecated @hapi/hoek@8.5.1: This version has been deprecated and is no longer supported or maintained
WARN deprecated har-validator@5.1.5: this library is no longer supported
WARN deprecated uuid@3.4.0: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain cir
cumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.
WARN deprecated @hapi/joi@15.1.1: Switch to 'npm install joi'
WARN deprecated uuid@3.4.0: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain cir
cumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.
WARN deprecated fsevents@1.2.13: fsevents 1 will break on node v14+ and could be using insecure binaries. Upgrade to fse
vents 2.
WARN deprecated @hapi/hoek@8.5.1: This version has been deprecated and is no longer supported or maintained
WARN deprecated querystring@0.2.0: The querystring API is considered Legacy. new code should use the URLSearchParams API
instead.
WARN deprecated source-map-resolve@0.5.3: See https://github.com/lydell/source-map-resolve#deprecated
WARN deprecated chokidar@2.1.8: Chokidar 2 does not receive security updates since 2019. Upgrade to chokidar 3 with 15x
fewer dependencies
WARN deprecated svgo@1.3.2: This SVGO version is no longer supported. Upgrade to v2.x.x.
WARN deprecated resolve-url@0.2.1: https://github.com/lydell/resolve-url#deprecated
WARN deprecated urix@0.1.0: Please see https://github.com/lydell/urix#deprecated
WARN deprecated source-map-url@0.4.1: See https://github.com/lydell/source-map-url#deprecated
WARN deprecated core-js@2.6.12: core-js@<3.4 is no longer maintained and not recommended for usage due to the number of
issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even i
f nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js.
|
配置 scss
全局样式文件 ⇧
虽然 vite
原生支持 less/sass/scss/stylus
,但是你必须手动安装他们的预处理器依赖
- 安装依赖
dart-sass
,但包的名字为 sass
- 不要安装为
dart-sass
:deprecated dart-sass@1.25.0: This package has been renamed to 'sass'.
1
2
|
# yarn add sass --dev
pnpm add sass -D
|
在 src/assets
下新增 style
文件夹,用于存放全局样式文件
- 新建
src/assets/style/main.scss
, 设置一个用于测试的颜色变量 :
- 配置
vite.config.ts
,让 vite
识别 scss
全局变量
1
2
3
4
5
6
7
8
9
10
|
...
css:{
// 指定传递给 CSS 预处理器的选项
preprocessorOptions:{
scss:{
additionalData:'@import "@/assets/style/main.scss";'
}
}
},
...
|
在任意组件中使用,不需要任何引入可以直接使用全局scss
定义的变量
1
2
3
|
.test{
color: $test-color;
}
|
移动端适配 px to viewport(vw/vh)
使用postcss-px-to-viewport
,将px单位转换为视口单位的 (vw, vh, vmin, vmax) 的 PostCSS 插件
- 样式需要做根据视口大小来调整宽度,这个脚本可以将你CSS中的px单位转化为vw,1vw等于1/100视口宽度
换用 @jonny1994/postcss-px-to-viewport
支持TS
引入配置文件vite.config.ts
参考
对TS
的相关配置 ⇧
vue/cli
项目中shimes-vue.d.ts
文件的作用
- 为了
typescript
做的适配定义文件,因为.vue
文件不是一个常规的文件类型
ts
是不能理解 .vue
文件是干嘛的 加这个文件是是告诉 ts,.vue
文件是这种类型的。
vite
项目中env.d.ts
文件的作用
- 为了声明 在
vite
中使用env
环境的配置 可以通过 import.meta.env.<变量名称来访问>
vue-tsc
和 tsc
tsc
只能验证 ts 代码类型
vue-tsc
可以验证 ts + Vue Template
中的类型(基于 Volar)
JSX
支持 ⇧
Vue 3 JSX 相关语法
使用tsx
风格代替template
模板方式,需要注意相应改变的地方:
src/router/
引入页面文件 .tsx
代替 .vue
文件
src/main.ts
修改引入 App
文件 .tsx
代替 .vue
文件
安装依赖插件
1
2
|
# yarn add @vitejs/plugin-vue-jsx -D
pnpm add @vitejs/plugin-vue-jsx -D
|
配置tsconfig.json
,在.tsx文件里支持JSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
{
"compilerOptions": {
"target": "esnext",
"useDefineForClassFields": true,
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"jsx": "preserve",// 在.tsx文件里支持JSX
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"lib": [
"esnext",
"dom"
],
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*.js",
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx", // 在.tsx文件里支持JSX
"src/**/*.jsx",
"src/**/*.vue"
]
}
|
改造App.vue
为App.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// This starter template is using Vue 3 <script setup> SFCs
// Check out https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup
import HelloWorld from '@/components/HelloWorld.vue';
import {defineComponent} from 'vue';
import appClass from '@/styles/app.module.scss' // css modules
import logoImg from '@/assets/logo.png'; // static assets
// 用defineComponent定义组件且要导出
// noinspection JSXNamespaceValidation
export default defineComponent({
render: () => (
<>
<img alt="Vue logo" src={logoImg}/>
<HelloWorld msg="Hello Vue 3 + TypeScript + Vite"/>
<router-link to="/">Go to Home</router-link>
<router-link to="/about">Go to About</router-link>
<router-view></router-view>
</>
),
});
|
在*.tsx
文件中使用css modules
⇧
配置vit.config.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path'; // @types/node
import vueJsx from '@vitejs/plugin-vue-jsx';
import ...
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
VueSetupExtend(),
autoComponents({...}),
AutoImport({...}),
],
server: {...},
resolve: {
alias: {...},
},
css: {
// 配置 css modules 的行为
modules: {
scopeBehaviour: 'local', // 作用域
// generateScopedName: // 使用默认配置,
localsConvention: 'camelCase', // 识别样式命名方式
},
preprocessorOptions: {
scss: {
additionalData: `...`,
}
}
},
});
|
- 定义一个
*.module.scss
或者 *.module.css
文件
- 创建
src/styles/app.module.scss
1
2
3
4
5
6
7
8
|
.app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import HelloWorld from '@/components/HelloWorld.vue';
import {defineComponent} from 'vue';
import appClass from '@/styles/app.module.scss' // css modules
import logoImg from '@/assets/logo.png'; // static assets
// 用defineComponent定义组件且要导出
export default defineComponent({
render: () => (
<main class={appClass.app}>
<img alt="Vue logo" src={logoImg}/>
<HelloWorld msg="Hello Vue 3 + TypeScript + Vite"/>
<router-link to="/">Go to Home</router-link>
<router-link to="/about">Go to About</router-link>
<router-view></router-view>
</main>
),
});
|
- 样式文件
xxx.module.scss
中定义类样式.yyy {...}
- 引入到
*.tsx
文件后,在模板中使用<main class={xxx.yyy}>...</main>
如何优雅的在TSX
语法中切换多个className
?
1
2
3
4
5
6
7
|
<div className={
this.state.isHide?styles.hide+' ':''+
this.state.isActive?styles.active+' ':''+
this.state.isAlarm?styles.alarm+' ':''
}>
{...data}
</div>
|
- 使用第三方包
classnames
- 安装
pnpm add classnames
- 引入
import classname from 'classnames'
上述代码可替换为:
1
2
3
4
5
6
7
8
9
|
dataClass: {
isHide: true,
isActive: true,
isAlarm: true
}
<div className={classname(dataClass)}>
{...data}
</div>
|
或者使用数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
// tsx
...
// multiClass
import classnames from 'classnames';
// CSS module
import basic from '@/styles/basic.module.scss';
import test1 from '@/styles/test1.module.scss';
import test2 from '@/styles/test2.module.scss';
const btnClass = [test1.test1, test2.test2]
export default defineComponent({
name: 'BlogIndex',
props: {},
setup(/*props, ctx*/) {
return {
};
},
render() {
return (
<>
<Button class={classnames(...btnClass, basic.blogBtn)}>
博客首页
</Button>
</>
);
}
});
|
和普通样式写在一起
1
2
|
<section class={classNames([cssDetail.article, 'article'])}>
</section>
|
参考
TSX
基础语法 ⇧
替换v-bind
语法
1
2
3
4
5
|
// vue
<template>
<van-circle v-bind:radius="d/2"/>
<van-circle :radius="d/2" />
</template>
|
1
2
3
4
5
|
...
return (
<van-circle radius={this.d/2}/>
)
...
|
替换v-model
语法
1
2
3
4
5
|
// vue
<template>
<van-circle v-model:current-rate="currentRate" />
<van-circle :current-rate="currentRate" />
</template>
|
1
2
3
|
// tsx
...
<van-circle v-model={[state.currentRate, 'current-rate']} />
|
替换v-for
语法
1
2
3
4
5
6
7
8
9
|
// vue
<script>
/* key尽量使用 item.id 。迫不得已使用 index */
const list = [1,2,3]
</script>
<template>
<li v-for="item in [1,2,3]" :key="item">{{item}}</li>
</template>
|
1
2
3
4
5
6
7
|
// jsx
{
list.map(item => (
<li key={item}> { item } </li>
))
}
|
- 此处如果
item
是一个对象,可以用解构语法来取出对象属性
- 需要指定
key
属性,来优化diff
替换v-if
跟v-else
语法
1
2
3
4
5
6
7
8
9
|
// vue
<script>
const flag = true;
</script>
/* v-if */
<template>
<div v-if="flag" />
</template>
|
1
2
|
// tsx
{ flag && <div /> }
|
/* v-else */
1
2
3
4
5
|
// vue
<template>
<div v-if="flag" />
<div v-else />
</template>
|
1
2
3
4
|
// tsx
{
flag ? <div>真</div> : <div>假</div>
}
|
替换@click
等事件
1
2
3
4
5
|
// vue
<template>
<div @click="handleClick" />
<div @change="handleChange">
</template>
|
1
2
3
|
// tsx
<input onClick={handleClick} />
<input onChange={handleChange} />
|
- 监听事件想到用
onChange
, onClick
等
- 需要注意的是
- 传参数不能使用
onClick={this.removePhone(params)}
- 这样子会每次
render
的时候都会自动执行一次方法
- 应该使用
bind
,或者箭头函数来传参
- 原生事件必须监听到存在对应事件的标签上,比如
input
标签的input
事件,否则报错
1
2
3
4
5
6
7
8
9
|
// tsx
<button type="button"
onClick={this.handleClick.bind(this, 11)}>
使用bind传参
</button>
<button type="button"
onClick={(/*event: MouseEvent*/) => this.handleClick(11)}>
使用箭头函数
</button>
|
JSX 自定义事件
- 需要在子组件的选项中声明
emits: ['diyEvent'],
- 父组件中使用 on + 驼峰式命名 的形式,例如
<Son onDiyEvent={...}/>
使用自定义事件
- 要声明一个自定义事件:
onHandleSubmit
- 子组件
- 声明选项
emits: ['handleSubmit',],
- 在
setup
选项中传参setup(props, ctx) {}
- 定义 发布方法
const clickHandler = () => {ctx.emit('handleSubmit');}
- 返回
return {clickHandler}
- 在
render
方法中使用render() {return <button onClick={this.clickHandler}></button>}
- 父组件
- 在
setup
选项中
- 定义 处理逻辑的方法
const clickHandler = () => {}
- 在
render
方法中使用自定义事件 <UserSubmitBtnTip onHandleSubmit={this.clickHandler}/>
判断键盘抬起时,键盘值
- 自定义组件中有
<input/>
,事件keyup
不能绑定在自定义组件上,而是绑定在内部的的<input/>
上
- 通过
props
传一个keyUpHandler
到内部
- 无修饰符,只能使用
event: KeyboardEvent
中的event.key
来判断,是否按下Enter
键
["Enter"].includes(event.key)
UserInput.tsx
Login.tsx
参考
用监听事件来实现 v-modal
双向绑定
1
2
3
4
5
6
7
8
|
// tsx
const doInput = (event: MouseEvent) => { this.text = e.target.value }
...
<input type="text"
value={this.text}
onInput={doInput}/>
|
事件修饰符
和指令一样,除了个别的之外,JSX 中大部分的事件修饰符都无法在像模板那样直接使用
替代方案:
.stop
: 阻止事件冒泡,在TSX
中使用event.stopPropagation()
来代替
.prevent
:阻止默认行为,在TSX
中使用event.preventDefault()
来代替
.self
与.enter
:只当事件是从侦听器绑定的元素本身触发时才触发回调,使用下面的条件判断进行代替
1
2
3
4
5
6
7
8
9
10
11
|
// tsx
if (event.target !== event.currentTarget){
return ...
}
// .enter与keyCode: 在特定键触发时才触发回调
if(event.keyCode === 13) {
// 执行逻辑
}
|
使用vue
自带的withModifiers()
方法
1
2
3
4
5
6
7
8
9
10
11
|
import { withModifiers } from 'vue'
const handleClick = withModifiers() => {
// ...
}, ['stop', 'prevent'])
// tsx
<button type="button"
onClick={this.handleClick}>
使用bind传参
</button>
|
替换插槽v-slot
1
2
3
4
5
6
7
8
|
// vue
<template>
<van-nav-bar title="标题" left-text="返回" left-arrow>
<template #right>
<van-icon name="search" size="18" />
</template>
</van-nav-bar>
</template>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
// tsx
<van-nav-bar title="标题"
left-text="返回"
left-arrow
v-slots={{ 'left': () => <van-icon name="search" size="18" /> }}
...
// 子组件
export default defineComponent({
name: "Test",
render() {
return (
<>
<span>I'm Child</span>
{ this.$slots.default?.() }
{ this.$slots.header?.() }
</>
)
}
})
// 父组件
import TestComponent from './TestComponent'
export default defineComponent({
name: "Test",
components: {
TestComponent
},
render() {
return (
<TestComponent v-slots={{
default: () => (
<>这是默认插槽</>
),
header: () => (
<>这是header插槽</>
)
}}>
</TestComponent>
)
}
})
|
- 父组件通过
v-slots
属性去定义插槽,子组件直接在 render
中通过 this.$slots[name]
去获取
作用域插槽
- 作用域插槽在JSX中的写法也一样,只需要将scope作为参数传递即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// vue
// 子组件
<template>
<div>
<span>I'm Child</span>
<slot name="content" :value="value"></slot>
</div>
</template>
<script setup>
const value = ref({name: 'xzw'})
</script>
// 父组件
<template>
<TestComponent>
<template #content="scope">
{{ scope.value.name }}
</template>
</TestComponent>
</template>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
// tsx
// 子组件
import { defineComponent } from "@vue/runtime-core";
export default defineComponent({
name: "Test",
setup() {
return {
value: {
name: 'xzw'
}
}
},
render() {
return (
<>
<span>I'm Child</span>
{ this.$slots.content?.(this.value) }
</>
)
}
})
// 父组件
import TestComponent from './TestComponent'
export default defineComponent({
name: "Test",
components: {
TestComponent
},
render() {
return (
<TestComponent v-slots={{
content: scope => (
<>{scope.name}</>
)
}}>
</TestComponent>
)
}
})
|
- 作用域插槽
this.$slots.default?.(this.value)
this.$slots.content?.(this.value)
参考
vite
常用插件
自动导入插件 unplugin-auto-import/vite
⇧
setup
语法让我们不用再一个一个的把变量和方法都return出去就能在模板上使用,大大的解放了我们的双手
- 然而对于一些常用的Vue API,比如ref、computed、watch等,还是每次都需要在页面上手动进行import
- 可以通过unplugin-auto-import实现自动导入,无需import即可在文件里使用Vue的API
参考文章:
自动导入组件unplugin-vue-components
⇧
安装
1
|
pnpm add unplugin-vue-components -D
|
配置vite.config.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';
import vueJsx from '@vitejs/plugin-vue-jsx';
// 自动引入组件
import autoComponents from 'unplugin-vue-components/vite';
import {
ElementPlusResolver,
AntDesignVueResolver,
VantResolver,
HeadlessUiResolver,
ElementUiResolver
} from 'unplugin-vue-components/resolvers';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
// unplugin-vue-components/vite
autoComponents({
// ui库解析器,也可以自定义
// resolvers: [ElementPlusResolver()],
resolvers: [
ElementPlusResolver(),
AntDesignVueResolver(),
VantResolver(),
HeadlessUiResolver(),
],
// 指定组件位置,默认是src/components
dirs: ['src/components'],
// valid file extensions for components.
// 组件的有效文件扩展名。
extensions: ['vue', 'tsx'],
// 配置文件生成位置
dts: 'src/components/components.d.ts',
// search for subdirectories
// 搜索子目录
deep: true,
// Allow subdirectories as namespace prefix for components.
// 允许子目录作为组件的命名空间前缀。
directoryAsNamespace: false,
// filters for transforming targets
include: [/.vue$/, /.vue?vue/],
exclude: [/[\/]node_modules[\/]/, /[\/].git[\/]/, /[\/].nuxt[\/]/],
})
],
...
});
|
自动导入vue3的hooks ⇧
效果
1
2
3
4
5
6
7
8
|
// 引入前
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
//引入后
const count = ref(0)
const doubled = computed(() => count.value * 2)
|
1
2
3
4
5
6
7
8
9
10
11
12
|
// 引入前
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return <div>{ count }</div>
}
// 引入后
export function Counter() {
const [count, setCount] = useState(0)
return <div>{ count }</div>
}
|
目前模板支持自动引入 api 的库列表包括:
- vue
- pinia
- vueuse
- vue-router
安装
1
2
|
# yarn add unplugin-auto-import -D
pnpm add unplugin-auto-import -D
|
vite.config.ts
配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
...
export default defineConfig({
...,
plugins: [
AutoImport({
imports: ['vue', 'vue-router', 'vue-i18n', '@vueuse/head', '@vueuse/core'],
// 可以选择auto-import.d.ts生成的位置,使用ts建议设置为'src/auto-import.d.ts'
// dts: 'src/auto-import.d.ts'
}),
...
]
})
|
- 原理: 安装的时候会自动生成
auto-imports.d
文件(默认是在根目录下src/auto-imports.d
)
- 可以选择
auto-import.d.ts
生成的位置,使用ts建议设置为src/auto-import.d.ts
- 其他插件 vue-router, vue-i18n, @vueuse/head, @vueuse/core等自动引入的自动引入请查看文档
vue-global-api
解决未显示导入依赖导致 eslint 报错 ⇧
- 在页面没有显示引入的情况下,使用
unplugin-auto-import/vite
来自动引入hooks
时
- 在项目中肯定会报错的,这时需要在
eslintrc.js
中的extends
引入vue-global-api
- 这个插件是
vue3hooks
的,其他自己找找,找不到的话可以手动配置一下globals
vue-global-api
文档
自动UI库引入样式插件vite-plugin-style-import
⇧
setup name
增强插件 ⇧
- 使用setup语法糖带来的第一个问题就是无法自定义name属性
- 而使用
keep-alive
往往是需要定义name
的
- 解决这个问题通常是通过写两个script标签来解决,一个使用
setup
,一个不使用
- 但这样必然是不够优雅的,见 5个知识点,让 Vue3 开发更加丝滑
- 借助插件
vite-plugin-vue-setup-extend
可以更优雅的解决这个问题
- 不用写两个
script
标签,可以直接在script
标签上定义name
安装vite-plugin-vue-setup-extend
1
2
|
# yarn add vite-plugin-vue-setup-extend -D
pnpm add vite-plugin-vue-setup-extend -D
|
配置vite.config.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// vite.config.ts
import { defineConfig } from 'vite'
...
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
export default defineConfig({
...,
plugins: [
...,
VueSetupExtend(),
...,
],
...
})
|
在setup
语法糖中使用name
属性
1
2
3
4
5
6
7
|
<script lang="ts" setup name="OrderList">
import { onMounted } from 'vue'
onMounted(() => {
console.log('mounted===')
})
</script>
|
引入svg图标 ⇧
使用现成的vite插件,有三个选择:
- 推荐 vite-plugin-svg-icons
- unplugin-icons (改名前身为
vite-plugin-icons
,默认使用Iconify
图标库)
- Vite SVG loader
vite-svg-loader
单独引入*.svg
文件文件,封装为Vue组件使用
- 使用
svgo
和 svgstore
自己写vite
插件
参考 使用 阿里巴巴 iconfont
手动自制vite
插件src/plugins/svgBuilder.js
第三方图标库 Iconify
https://juejin.cn/post/7063415079928070157 | 前端工程化之SvgIcon组件 - 掘金
https://juejin.cn/post/6990159719558021133 | 工程化使用 SVG 图标最佳实践 - 掘金
https://juejin.cn/post/7055878408365932557 | 2022年大厂前端必备,开箱即用的Vue3+Vite2最强模板 - 掘金
https://juejin.cn/post/7062681169627709448 | iconfont的原理与实践 - 掘金
https://juejin.cn/post/7000560093804625957 | icones:支持即时搜索的图标资源管理器,尤雨溪等开发者赞助支持 - 掘金
https://juejin.cn/post/7067418893899268103 | 采用 @svgr/webpack 支持 svg 作为组件引入 - 掘金
https://juejin.cn/post/6863414887931084814 | Vue项目中使用svg小图标,可修改大小颜色 - 掘金
https://juejin.cn/post/7017657405265674271 | vue工程使用svg图片,svg-sprite-loader缺陷,你们的按需加载svg是错误的。 - 掘金
https://juejin.cn/post/6975380991762235422 | 当webpack有了vite的速度你会喜欢吗? - 掘金
markdown
插件,用户文本输入文章,插件渲染成网页 ⇧
安装marked
1
2
|
pnpm add marked
pnpm add -D @types/marked
|
引入
参考
其他markdown
方案
其他插件
vite
项目中安装vue-router@4
⇧
- 需要先配置别名
alias
,将src/
映射为@
,详见插件部分
在 src 文件下新增 router 文件夹 => index.ts
文件,内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
import {createRouter, /*createWebHistory, hash 模式 */ createWebHashHistory, RouteRecordRaw} from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Login',
component: () => import('@/pages/login/Login.vue'), // 注意这里要带上 文件后缀.vue
},
];
const router = createRouter({
history: createWebHashHistory(), // hash 模式
routes,
});
export default router;
|
修改入口文件 mian.ts
:
1
2
3
4
5
6
7
|
import {createApp} from 'vue';
import App from './App.vue';
import router from './router/index';
const app = createApp(App);
app.use(router);
app.mount('#app');
|
到这里路由的基础配置已经完成了,更多配置信息可以查看 vue-router
官方文档:
vue-router
: https://next.router.vuejs.org/zh/guide/
vue-router4.x
支持 typescript
,配置路由的类型是 RouteRecordRaw
,这里 meta
可以让我们有更多的发挥空间,这里提供一些参考:
title:string;
页面标题,通常必选。
icon?:string;
图标,一般配合菜单使用。
auth?:boolean;
是否需要登录权限。
ignoreAuth?:boolean;
是否忽略权限。
roles?:RoleEnum[];
可以访问的角色
keepAlive?:boolean;
是否开启页面缓存
hideMenu?:boolean;
有些路由我们并不想在菜单中显示,比如某些编辑页面。
order?:number;
菜单排序。
frameUrl?:string;
嵌套外链。
keep-alive
缓存路由
对axios
的封装 ⇧
安装
导入
使用
安装pinia
⇧
安装
导入
使用
代替安装vuex
⇧
安装
导入
使用
vite
其他配置 ⇧
- 别名配置
- 配置
scss
全局样式文件
css modules
mockjs
模拟数据配置
- 配置多环境变量
- 动态导入环境配置
esbuild
一些打包配置
别名配置 ⇧
- 导入
path
的时候提示报错,需要安装 @types/node
类型检测
1
|
pnmp add @types/node -D
|
修改 vite.config.ts
文件配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 导入 path 的时候提示报错,需要安装 @types/node 类型检测
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
...
})
|
修改 tsconfig.json
文件配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["esnext", "dom"],
"baseUrl": ".",
"paths": {
"@/*":["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
|
mockjs
模拟数据配置
安装
1
|
pnpm add mockjs vite-plugin-mock cross-env -D
|
配置package.json
字段script > dev
1
2
3
|
...
"dev": "cross-env NODE_ENV=development vite"
...
|
配置vite.config.ts
1
2
3
4
5
6
7
8
9
10
11
|
import { viteMockServe } from 'vite-plugin-mock'
plugins: [
vue(),
viteMockServe({
mockPath: './mock',
supportTs: true
}),
...
],
|
supportTs: true,
是否用了ts,可以根据自己选择true or false
- 要将
watchFiles: true,
文件夹监听打开,这样更改mock的时候,不需要重新启动编译
使用
- 在与
node_modules
同级 目录建立mock
目录
mock
目录下建立mock
文件 user.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
import { MockMethod } from 'vite-plugin-mock'
export default [
{
url: '/api/getUser',
method: 'get',
response: (req: Record<string, unknown> /*拿到请求的数据*/) => {
// console.log('body>>>>>>>>')
return {
code: 0,
message: 'ok',
data: ['aa', 'bb']
}
}
}
] as MockMethod[]
|
使用
1
2
3
4
5
6
|
// 代码请求
axios.get('/api/getUser')
.then(res => {
console.log(res)
})
|
参考
按照接口文档模拟数据
入口 api-mock.ts
https://juejin.cn/post/7032926228055556126 | vite + typesctipt+mockjs搭建mock环境 - 掘金
https://juejin.cn/post/6910014283707318279 | 备战2021:vite工程化实践,建议收藏 - 掘金
https://juejin.cn/post/7039879176534360077 | 关于 vite.config.js 相关配置,拿走不谢 - 掘金
https://juejin.cn/post/7053717060295065614 | vue3+vite+ts+vuex+vue-router+Element-plus+tailwindcss+mock 搭建完整项目 - 掘金
https://juejin.cn/post/7028481421022855175 | vite.config.js常用配置 - 掘金
https://www.cnblogs.com/linbudu/p/11375775.html | Vue项目中引入Mock.js & Mock.js语法整理 - 林不渡 - 博客园
https://github.com/nuysoft/Mock/wiki/Mock.mock() | Mock.mock() · nuysoft/Mock Wiki · GitHub
https://github.com/enjoycoding/vite-plugin-mock-server | GitHub - enjoycoding/vite-plugin-mock-server: A mock server plugin for Vite.
https://juejin.cn/post/6901615700364918791 | vue中mock.js的使用 - 掘金
https://juejin.cn/search?query=vite-plugin-mock-server | vite-plugin-mock-server - 搜索 - 掘金
https://juejin.cn/post/6993740289605124126 | vite-plugin常用的插件 - 掘金
https://juejin.cn/post/7000283590642630687 | Vue3 + Vite2 + Vue-Router 4.x + Vuex 4.x + Element-Plus + Axios + Mockjs 项目搭建 - 掘金
https://juejin.cn/post/6936771237452464165 | VUE3 项目开发实战入门系列 (7.-Mock接口) - 掘金
https://juejin.cn/post/6844903492235034632 | Mockjs,再也不用追着后端小伙伴要接口了 - 掘金
https://github.com/Tencent/APIJSON | GitHub - Tencent/APIJSON: 🚀 零代码、热更新、全自动 ORM 库,后端接口和文档零代码,前端(客户端) 定制返回 JSON 的数据和结构。 🚀 A JSON Transmission Protocol and an ORM Library for automatically providing APIs and Docs.
https://juejin.cn/post/7048916480032768013 | 「前端该如何优雅地Mock数据🏃」每个前端都应该学会的技巧 - 掘金
https://juejin.cn/post/7043420433613324325 | Vite + Vu3项目中 Mockjs 插件的学习使用 - 掘金
https://juejin.cn/post/7040701806887993381 | 两个项目实例+常用语法解析带你掌握Mock.js✨ - 掘金
https://www.cnblogs.com/student007/p/15180190.html | (o゚v゚)ノ Hi - vite使用mock插件(vite-plugin-mock)记录
https://www.jianshu.com/p/72fffae58761 | vite2+vue3项目中引入vite-plugin-mock - 简书
https://www.cnblogs.com/leiting/p/15125707.html | vite mock 数据插件:vite-plugin-easy-mock - c-137Summer - 博客园
https://www.npmjs.com/package/cross-env | cross-env - npm
https://www.jianshu.com/p/e8ba0caa6247 | cross-env使用 - 简书
配置开发/生产环境变量 ⇧
基本概念
不单单是开发环境或生产环境,api 请求的域名会根据不同环境而不同
- 线上环境 和 测试环境 在打包策略有所不同「如线上要隔离sourceMap、屏蔽vue|react devtools等…」
- 前端 SPA 组件根据不同环境做出不同逻辑
环境变量和多环境适配的 Vite 实现
dev
开发环境
test
测试环境
beta
公测版环境
release
生产发布环境
- 环境变量
.env
- 把系统配置放到
config/constant.ts
管理了
- 为了方便管理不同环境的接口和参数配置
- 可以使用环境变量
.env
- 如
.env.development
、.env.production
- 配合
vite + import.meta.env
使用
参考
动态导入环境配置 ⇧
安装
导入
使用
esbuild
一些打包配置 ⇧
vite.config.ts
完整配置 ⇧
项目结构与组件样式 ⇧
布局
1
2
3
4
5
6
7
|
<div>
<BlogHeader></Header>
<main>
<router-view/>
</main>
<BlogFooter></Footer>
</div>
|
App.tsx
根组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
import Layout from '@/components/Layout';
import {defineComponent, provide} from 'vue';
// Comps ant
import {message} from 'ant-design-vue';
// CSS Style
import 'ant-design-vue/es/button/style/index.css';
import 'ant-design-vue/es/message/style/index.css';
// 用defineComponent定义组件且要导出
export default defineComponent({
name: 'App',
// components: {BlogHeader, BlogFooter, Button}, // 无需注册组件,components是用于template模板中
setup(/*props, ctx*/) {
// 将 message 方法挂载到全局
provide('$message', message);
},
render: () => {
const renderLayout = () => { return (<Layout/>)}
return (
renderLayout()
);
},
});
|
App.tsx
根组件按需引入UI组件库样式
App.tsx
根组件使用provide
提供全局 message
方法
App.tsx
根组件引入布局组件Layout.tsx
布局组件Layout.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
import {defineComponent,} from 'vue';
// Comps
import BlogHeader from '@/components/BlogHeader';
import BlogFooter from '@/components/BlogFooter';
// css modules
import layoutClass from '@/styles/layout.module.scss';
const LayoutProps = {
isSHow: Boolean
};
export default defineComponent({
name: 'Layout',
props: LayoutProps,
setup(/*props, ctx*/) {
return {};
},
render() {
const renderBlogHeader = () => {return (<BlogHeader class={layoutClass.blogHeader}/>);};
const renderBlogFooter = () => {return (<BlogFooter class={layoutClass.blogFooter}/>);};
return (
<div class={layoutClass.app}>
{renderBlogHeader()}
<main class={layoutClass.blogMain}>
<router-view/>
</main>
{renderBlogFooter()}
</div>
);
}
});
|
layout.module.scss
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
// layout 布局
.app {
display: grid;
grid-template-areas: "header header header"
". main ."
"footer footer footer";
grid-template-columns: 12% auto 12%;
grid-template-rows: auto 1fr auto;
height: 100vh;
.blog-header {
grid-area: header;
padding-left: 12%;
padding-right: 12%;
}
.blog-main {
grid-area: main;
}
.blog-footer {
align-self: end;
grid-area: footer;
padding-left: 12%;
padding-right: 12%;
}
}
@media (max-width: 768px) {
.app {
grid-template-columns: 10px auto 10px;
.blog-header,
.blog-footer {
padding-left: 10px;
padding-right: 10px;
}
}
}
|
- layout 布局只控制页面结构,组件具体的样式在各自组件中写
在<BlogHeader/>
组件中,判断用户是否登录,从而展示不同的登录样式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
// BlogHeader.tsx
import {computed, defineComponent, ref} from 'vue';
import {Button,} from 'ant-design-vue';
// CSS module
import classNames from 'classnames';
import blogClass from '@/styles/blog.module.scss';
import basic from '@/styles/basic.module.scss';
const BlogHeaderProps = {
isSHow: Boolean
};
export default defineComponent({
name: 'BlogHeader',
props: BlogHeaderProps,
components: {},
setup(/*props, ctx*/) {
const isLogin = ref(false);
const isLoginClass = computed(() => {
console.log(blogClass.login);
return (
isLogin
? [blogClass.blogHeader, blogClass.login]
: [blogClass.blogHeader]);
});
return {
isLogin,
isLoginClass
};
},
render() {
const renderBtn = (btnString: string) => (<Button class={basic.blogBtn}>{btnString}</Button>);
const renderUnLogin = () => (
<div class={blogClass.btns}>
{renderBtn('立即登录')}
{renderBtn('注册账号')}
</div>
);
const renderLogin = () => (
<>
<i>123</i>
<img src="" alt=""/>
</>
);
return (
<header class={classNames(...this.isLoginClass)}>
<h1>Let's share</h1>
<p>精品博客汇聚</p>
{this.isLogin
? renderLogin()
: renderUnLogin()
}
</header>
);
}
});
|
src/styles/blog.module.scss
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
|
@import 'src/assets/style/variables';
%h1-style {
color: $white;
font-size: 40px;
text-transform: uppercase;
}
.blog-header {
// 默认为未登录状态
background: $bg-color-veg;
display: grid;
justify-items: center;
padding: 0 12% 30px;
.slogan {
@extend %h1-style;
margin: 60px 0 0;
}
.tips {
color: $white;
margin: 15px 0 0;
}
.btns {
margin-top: 20px;
}
button {
margin: 20px 5px 0;
}
}
// 切换为登录状态 覆盖未登录状态样式
.login {
align-items: center;
background: $bg-color-veg;
display: flex;
.slogan {
@extend %h1-style;
flex: 1;
margin: 0;
padding: 0;
}
.user {
.edit-icon {
color: $white;
font-size: 30px;
}
.avatar {
border: 1px solid $white;
border-radius: 50%;
height: 40px;
margin-left: 15px;
width: 40px;
}
}
}
.blog-footer {
background-color: $bg-color-footer;
color: $font-color-footer;
font-size: 13px;
padding: 10px;
text-align: center;
}
|
其他静态页面 ⇧
src/pages/blog/index/BlogIndex.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
import {message} from 'ant-design-vue';
import {defineComponent, inject} from 'vue';
// CSS module
// import basic from '@/styles/basic.module.scss';
import blogIndex from '@/styles/blog-index.module.scss';
// multiClass
// const btnClass = [basic.blogBtn];
import blogApi from '@/api/blog';
const {getBlogs} = blogApi;
export default defineComponent({
name: 'BlogIndex',
props: {},
setup(/*props, ctx*/) {
const popMessage = inject<typeof message>('$message');
const getBlogList = async () => {
const BlogDataList = await getBlogs();
};
return {
popMessage
};
},
render() {
return (
<>
<section class={blogIndex.blogPost}>
<div class={blogIndex.item}>
<figure class={blogIndex.avatar}>
<img class={blogIndex.img} src="" alt=""/>
<figcaption class={blogIndex.info}>姓名</figcaption>
</figure>
<h3 class={blogIndex.title}>文章标题
<span class={blogIndex.date}>时间</span>
</h3>
<p class={blogIndex.article}>正文,最多显示前200字</p>
</div>
<div class={blogIndex.item}>
<figure class={blogIndex.avatar}>
<img class={blogIndex.img} src="" alt=""/>
<figcaption class={blogIndex.info}>姓名</figcaption>
</figure>
<h3 class={blogIndex.title}>文章标题
<span class={blogIndex.date}>时间</span>
</h3>
<p class={blogIndex.article}>正文,最多显示前200字</p>
</div>
<div class={blogIndex.item}>
<figure class={blogIndex.avatar}>
<img class={blogIndex.img} src="" alt=""/>
<figcaption class={blogIndex.info}>姓名</figcaption>
</figure>
<h3 class={blogIndex.title}>文章标题
<span class={blogIndex.date}>时间</span>
</h3>
<p class={blogIndex.article}>正文,最多显示前200字</p>
</div>
</section>
</>
);
}
});
|
``
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
@import 'src/assets/style/variables';
.blog-post {
.item {
display: grid;
grid: auto auto / 80px 1fr;
margin: 20px 0;
.avatar {
grid-column: 1;
grid-row: 1 / span 2;
justify-self: center;
margin-left: 0;
text-align: center;
.img {
width: 60px;
height: 60px;
border-radius: 50%;
}
.info {
font-size: 12px;
color: $theme-lighter-color;
}
}
.title {
grid-column: 2;
grid-row: 1;
& > .date {
color: $theme-lighter-color;
font-size: 12px;
font-weight: normal;
}
}
.article {
grid-column: 2;
grid-row: 2;
margin-top: 0;
}
}
}
|
参考文章 ⇧
相关文章
- 作者: Joel
- 文章链接:
- 版权声明
- 非自由转载-非商用-非衍生-保持署名