Angular Material快速建立各種元件

使用Angular Material來幫我們快速建立各種元件

Angular Material 官網

1
ng add @angular/material

創建to-do

新增components資料夾,右鍵『在整合終端機中開啟』,終端機輸入以下指令
這意味我要新增一個ToDo

1
2
3
ng g c 頁面或是功能
ng g c ToDo

生成的檔案會是『大寫小寫英文-大寫小寫英文.component』副檔名會有

  • .html:HTML檔案
  • .scss:樣式
  • .spec.ts:單元測試檔案
  • .ts:資料定義與函式

components 資料夾內會新增一個 to-do資料夾
資料件內有以下內容

  • to-do.component.html
  • to-do.component.scss
  • to-do.component.spec.ts
  • to-do.component.ts

在app資料夾內app.component.html 新增

1
2
3
4
<main class="main">
<app-to-do></app-to-do>
</main>
<router-outlet />
錯誤訊息:[ERROR] NG8001: 'app-to-do' is not a known element
處理方式:imports引入 ToDoComponent
在app資料夾內引入app.component.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
+ import { ToDoComponent } from './components/to-do/to-do.component';

@Component({
selector: 'app-root',
standalone: true,
imports: [
RouterOutlet,
+ ToDoComponent
],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {
title = 'tode_project';
}

安裝 Angular Material gitHub

Angular 第一個專案

安裝Angular CLI

node v20.15.1 (npm v10.7.0)
VSCode 擴充套件:Angular Extension Pack
若使用 VSCode(Visual Studio Code)編輯器,可安裝一個叫做 Angular Extension Pack 的擴充套件,整合了 Angular 相關擴充套件方便使用:

1
$ npm i -g @angular/cli@17

查看版本

1
2
ng version
17.3.12

Angular CLI: 17.3.12
Node: 20.15.1
Package Manager: npm 10.7.0
OS: darwin x64

建立Angular專案
1
ng new 專案名稱
Which stylesheet format would you like to use? 您想使用哪一種樣式表格式?Sass (SCSS)
[ https://sass-lang.com/documentation/syntax#scss ] ?
Do you want to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)? 您想要啟用伺服器端渲染 (SSR) 和靜態網站產生 (SSG/預渲染) 嗎?yes
CREATE test/README.md (1065 bytes)
CREATE test/.editorconfig (274 bytes)
CREATE test/.gitignore (587 bytes)
CREATE test/angular.json (2902 bytes)
CREATE test/package.json (1258 bytes)
CREATE test/tsconfig.json (1012 bytes)
CREATE test/tsconfig.app.json (485 bytes)
CREATE test/tsconfig.spec.json (434 bytes)
CREATE test/server.ts (1729 bytes)
CREATE test/.vscode/extensions.json (130 bytes)
CREATE test/.vscode/launch.json (470 bytes)
CREATE test/.vscode/tasks.json (938 bytes)
CREATE test/src/main.ts (250 bytes)
CREATE test/src/index.html (290 bytes)
CREATE test/src/styles.scss (80 bytes)
CREATE test/src/main.server.ts (264 bytes)
CREATE test/src/app/app.component.scss (0 bytes)
CREATE test/src/app/app.component.html (19903 bytes)
CREATE test/src/app/app.component.spec.ts (910 bytes)
CREATE test/src/app/app.component.ts (301 bytes)
CREATE test/src/app/app.config.ts (404 bytes)
CREATE test/src/app/app.routes.ts (77 bytes)
CREATE test/src/app/app.config.server.ts (350 bytes)
CREATE test/public/favicon.ico (15086 bytes)
✔ Packages installed successfully.
Successfully initialized git.
cd 專案名稱

Would you like to share pseudonymous usage data about this project with the Angular Team at Google under Google's Privacy Policy at https://policies.google.com/privacy. For more details and how to change this setting, see https://angular.dev/cli/analytics.

您願意與 Angular 團隊分享有關此項目的匿名使用數據嗎 根據 Google 的隱私權政策 (https://policies.google.com/privacy) 在 Google 進行操作。了解更多 詳細資訊以及如何更改此設置,請參閱 https://angular.dev/cli/analytics。

1
ng serve --open
這個項目是用生成的 [Angular CLI](https://github.com/angular/angular-cli)
version 18.2.4.

開發啟動

1
ng serve or npm run start

http://localhost:4200/

專案架構

├── node_modules // 安裝的所有套件
├── package.json //整個專案的設定檔,像是應用程式的名稱、版本、描述、關鍵字、授權、貢獻者、維護者、腳本、相依的相關套件及其版本資訊等等,詳細請參考官方文件的說明。
├── package-lock.json //整個專案的設定檔
├── public
│ └── favicon.ico
├── .angular
├── README.md //這個檔案是這個專案的說明文件,採用 Markdown 的語法。可以自由撰寫關於此專案的任何說明。
├── angular.json //Angular CLI 設定檔
├── tsconfig.app.json
├── tsconfig.json // TypeScript 編譯時看的編譯設定檔。
├── tsconfig.spec.json
├── .editorconfig
├── src //主要開發原始碼
├────├── app //包含整個網頁應用程式的 Module、Component、Service
├────├────└── app.routes.ts // 路由
├────├────└── app.config.ts
├────├────└── app.config.server.ts
├────├────└── app.component.ts //
├────├────└── app.component.spec.ts
├────├────└── app.component.scss
├────├────└── app.component.html
├────├────└── home
├────├────└────└── home.component.html //模板 Template
├────├────└────└── home.component.scss //樣式
├────├────└────└── home.component.spec //執行 ng test 命令會透過 Karma 進行測試
├────├────└────└── home.component.ts //元件 Component
├────├────└── page-not-found
├────├────└────└── page-not-found.component.html //模板 Template
├────├────└────└── page-not-found.component.scss //樣式
├────├────└────└── page-not-found.component.spec
├────├────└────└── page-not-found.component.ts
├────├────└── components // 子元件資料夾
├────├────├────└── navbar
├────├────├────└────└────navbar.component.html //模板 Template
├────├────├────└────└────navbar.component.scss //樣式
├────├────├────└────└────navbar.component.spec //執行 ng test 命令會透過 Karma 進行測試
├────├────├────└────└────navbar.component.ts //元件 Component
├────├── environments //環境變數設定檔
├────├────└── environment.prod.ts
├────├────└── environment.ts
├────├── index.html // 起始頁面1-1
├────├── main.server.ts
├────├── main.ts // 進入點載入:Angular CLI 在編譯與打包的時候,把這支檔案裡的程式,當做整個網頁應用程式的主要程式進入點。一般也是不會去動到這裡的程式碼。
├────│── server.ts
├────│── styles.scss

index.html 載入起始頁面1-1

在 index.html 檔案的 標籤內設定了,其定義應用程式執行的根路徑。此標籤是必要的,如果不設定就會讓應用程式找不到網頁,而拋出 404 - Not Found 的錯誤訊息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<title>AngularPermissionsProject</title>
<!------>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

Angular 專案架構

使用Angular Material來幫我們快速建立各種元件

Angular Material快速建立各種元件

產生一個新的組件,先安裝Angular Material

1
2
3
4
5
6
7
8
9
//先安裝Angular Material
ng add @angular/material
套件@angular/material@19.2.1 將被安裝並執行。
您要繼續嗎? (是/否)
The package @angular/material@19.2.1 will be installed and executed.
Would you like to proceed? (Y/n)

//ng g c 頁面或是功能
ng g c ToDo

生成的檔案會是『大寫小寫英文-大寫小寫英文.component』副檔名會有

  • .html:HTML檔案
  • .scss:樣式
  • .spec.ts:單元測試檔案
  • .ts:資料定義與函式

Build 建造

1
ng build

Run ng build 建構項目。建置工件將儲存在 dist/ 目錄。

運行單元測試

Run ng test 透過執行單元測試Karma.

運行端對端測試

Run ng e2e 透過您選擇的平台執行端對端測試。要使用該命令,您需要先新增一個實現端到端測試功能的套件。

Further help

要獲得有關 Angular CLI 使用的更多幫助 ng help 或去看看 Angular CLI Overview and Command Reference page.

Vite Vue單元測試實例-登入

登入頁的設計

基礎元件功能

  • 確認 email、password、verification、submit 元素存在
  • 確認 Email 輸入框可以填資料
  • 確認 Password 輸入框可以填資料
  • Email、Password 輸入後,data 對應的 formData 有存入相應的值
  • 預設 Password 輸入框的 type 為 password,不直接顯示

測試錯誤的 Email、Password、verification驗證情境

  • 當 Email 沒有填寫時,跳出 Required 的錯誤訊息
  • 當 Email 格式錯誤時,跳出相關錯誤訊息
  • 確認 Password 輸入框可以填資料
  • 當 Password 沒有填寫時,跳出 Required 的錯誤訊息
  • 當 Password 字數不符合 6~30 字符時,跳出相關錯誤訊息
  • 當 verification 沒有填寫時,跳出 Required 的錯誤訊息
  • 當 verification 字數不符合 4 字符時,跳出相關錯誤訊息

元件屬性的改變的行為

  • 當 Email、Password 未填寫時,登入按鈕為 Disabled
  • 當 Email、Password 格式錯誤時,登入按鈕為 Disabled
  • 當 Email、Password 格式正確時,登入按鈕可點擊
  • 點擊顯示 Password 的 icon 時,type 會轉換成 text

登入功能

  • 輸入錯誤的帳號密碼,會彈出警視窗
  • 確認 Router 可以順利導向

登入表單

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
//父組件
<script setup lang="ts">
import { ref,onMounted, getCurrentInstance,watchEffect,watch,computed } from 'vue'
//登入表單
const loginTitle = ref<string>('請先登入')
//登入表單標題Labels
const loginLabels = ref<loginFormType>({
email: '電子郵件',
password: '密碼',
verification: '驗證碼',
})
const loginForms = ref<loginFormType>({
email: '',
password: '',
verification: '',
})
//電子郵件驗證錯誤訊息
const messageCheckEmail = ref<string>('')
//電子郵件驗證函式
const checkEmail = () => {
const regEmail = /^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/;
if (loginForms.value.email == "") {
messageCheckEmail.value = '電子郵件不能為空!';
errorCheckEmail.value = true;
} else if (loginForms.value.email?.search(regEmail) == -1) {
messageCheckEmail.value = '電子郵件不符合規格!';
errorCheckEmail.value = true;
} else {
messageCheckEmail.value = '';
errorCheckEmail.value = false;
}
}

//密碼表單驗證錯誤訊息
const messageCheckPassword = ref<string>('');
//密碼表單驗證函式
const checkPassword = () => {
const regPassword = /[a-zA-Z0-9]{6,30}/;
if (loginForms.value.password == "") {
messageCheckPassword.value = '密碼不能為空!';
} else if (loginForms.value.password?.search(regPassword) == -1) {
console.log(loginForms.value.password.length,regPassword)
messageCheckPassword.value = '密碼必須是6~30個字符以上!';
} else {
messageCheckPassword.value = '';
}
}
// 切換眼睛改變密碼顯示或是隱藏
const passwordVisible = ref<boolean>(false);
const passwordType = ref<string>('password');
const showPassword = () => {
passwordVisible.value = !passwordVisible.value;
passwordVisible.value ? passwordType.value = 'type' : passwordType.value = 'password';
}

//驗證碼表單驗證錯誤訊息
const messageCheckVerification = ref<string>('');
//驗證碼表單驗證錯誤訊息
const checkVerification = () => {
const regVerification = /[a-zA-Z0-9]{4}/;
if (loginForms.value.verification == "") {
messageCheckVerification.value = '驗證碼不能為空!';
} else if (loginForms.value.verification?.search(regVerification) == -1 ) {
messageCheckVerification.value = '驗證碼必須是4個字符以上!';

} else if (loginForms.value.verification !=code_box.value) {
messageCheckVerification.value = '驗證碼錯誤!';
} else {
messageCheckVerification.value = '';
}
}
const code_box = ref<string>('')
const generateCode =(length=4)=>{
// let chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
let chars = "0123456789";
let code = "";
for (var i = 0; i < length; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
code_box.value=code;
}

//點擊獲得新的驗證碼
const showCode =() => {
generateCode();
}
const checkLoginForms =computed(()=>{
if (loginForms.value.email == "" || loginForms.value.password == "" || loginForms.value.verification == "") {
return true;
} else if( messageCheckEmail.value!='' || messageCheckPassword.value!='' || messageCheckVerification.value!=''){
return true;
}else{
return false;
}
})

const loginSubmit = () => {
checkLoginForms.value?'':router.push({path: `/list`})
}
generateCode();
</script>

<route lang="yaml">
meta:
layout: frontLayout
</route>

<template>
<LoginForm
:loginTitle="loginTitle"
:loginLabels="loginLabels"
:loginForms="loginForms"
:messageCheckEmail="messageCheckEmail"
@sendCheckEmail ="checkEmail"
:messageCheckPassword="messageCheckPassword"
@sendCheckPassword ="checkPassword"
:messageCheckVerification=" messageCheckVerification"
@sendCheckVerification ="checkVerification"
:code_box="code_box"
@sendShowCode ="showCode"
:checkLoginForms="checkLoginForms"
@sendLoginSubmit ="loginSubmit"
:errorCheckEmail="errorCheckEmail"
:errorCheckPassword="errorCheckPassword"
:errorCheckVerification="errorCheckVerification"
:passwordVisible="passwordVisible"
:passwordType="passwordType"
@sendShowPassword="showPassword"
/>
</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
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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
<script setup lang="ts">
const props = defineProps({
loginTitle: { type: String },
loginLabels: { type: Object },
loginForms: { type: Object },
messageCheckEmail: { type: String },
messageCheckPassword: { type: String },
messageCheckVerification: { type: String },
code_box: { type: String },
checkLoginForms: { type: Boolean },

errorCheckEmail: { type: Boolean },
errorCheckPassword: { type: Boolean },
errorCheckVerification: { type: Boolean },
passwordVisible: { type: Boolean },
passwordType: { type: String },
})
const emit = defineEmits(['sendCheckEmail','sendCheckPassword','sendCheckVerification','sendShowCode','sendLoginSubmit','sendShowPassword'])
const sendCheckEmail =()=>{
emit('sendCheckEmail')
}
const sendCheckPassword =()=>{
emit('sendCheckPassword')
}
const sendCheckVerification=()=>{
emit('sendCheckVerification')
}
const sendShowCode= ()=>{
emit('sendShowCode')
}
const sendLoginSubmit= ()=>{
emit('sendLoginSubmit')
}
const sendShowPassword = ()=>{
emit('sendShowPassword')
}
</script>

<template>
<div class="form login_form mt-5">
<div class="text-center mb-3">
<b data-test="title" >{{ loginTitle }}</b>
</div>
<div class="input_item">
<label>{{ loginLabels.email }}</label>
<input type="text"
data-test="email"
v-model="loginForms.email"
@blur="sendCheckEmail"
:placeholder="loginLabels.email"
:class="{error:errorCheckEmail}"
/>
<div
class="error_message"
data-test="emailMessage"
>{{ messageCheckEmail }}</div>
</div>

<div class="input_item">

<label>{{ loginLabels.password }}</label>
<div class="password_input">
<input
data-test="password"
:type="passwordType"
v-model="loginForms.password"

@blur="sendCheckPassword"
:class="{ error: errorCheckPassword }"
:placeholder="loginLabels.password"/>
<i class="icon-eye-close"
@click.prevent="sendShowPassword"
:class="passwordVisible === false ? 'icon-eye-close' : 'icon-eye'"
></i>



</div>
<div class="error_message"
data-test="passwordMessage">{{ messageCheckPassword }}</div>
</div>
<div class="input_item verification" >
<label>{{ loginLabels.verification }}</label>
<input type="text"
@blur="sendCheckVerification"
data-test="verification"
v-model="loginForms.verification"
:class="{error:errorCheckVerification}"
:placeholder="loginLabels.verification"/>
<div class="codeBox">{{ code_box }}</div>
<a class="btn_change">
<i @click="sendShowCode" class="icon-spinner11"></i>
</a>
<div class="error_message"
data-test="verificationMessage"
>{{ messageCheckVerification }}</div>
</div>
<div class="flex justify-center">
<div class="flex w-64">
<a class="btn cancel mr-3">取消</a>
<a class="btn submit"
@click="sendLoginSubmit"
:class="{ disabled: checkLoginForms }"
>送出</a>
</div>
</div>
</div>
</template>

單元測試的建置

1

Vite Vue (語法糖)表單父傳子的綁定

組件間的傳遞

錯誤訊息

[plugin:vite:vue] v-model cannot be used on a prop, because local prop bindings are not writable. Use a v-bind binding combined with a v-on listener that emits update:x event instead.
  • 從父母那裡傳遞我們的數據
  • 我們的孩子發出一個事件來更新父實例
父組件
v-model:表單傳遞的名稱值='表單傳遞的名稱值'
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
<script setup lang="ts">
import { ref } from 'vue'
import TodList from '../../../components/TodoList.vue'

interface todosType{
id: Number,
text: String,
completed: boolean,
}
const todos = ref<todosType[]>([
{
id: 1,
text: 'Learn Vue.js 3',
completed: false
}
])
const newTodo = ref<string>('')
const createTodo = () => {
const query = {
id: 2,
text: newTodo.value,
completed: false
}
todos.value.push(query)
}
</script>

<template>
<TodList
:todos="todos"
v-model:newTodo='newTodo'
@sendCreateTodo ="createTodo"
/>
</template>

子組件
:value="表單傳遞的名稱值"
@input="emitNewTodoValue"
emitNewTodoValue是函式=>如果不是空字符,更新表單傳遞的名稱值(往父系傳遞) const emitNewTodoValue = (evt) =>{ emit('update:newTodo', evt.target.value) }
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
<script setup>
const props = defineProps({
todos: { type: Object },
newTodo: { type: String },
// lastNewTodoModifiers: {
// default: () => ({}),
// 'no-whitespace': () => {}
// },
})
const emit = defineEmits(['update:newTodo','sendCreateTodo'])
const emitNewTodoValue = (evt) =>
{
// let val = evt.target.value
// if (props.lastNewTodoModifiers['no-whitespace']) {
// val = val.replace(/\s/g, '')
// }
emit('update:newTodo', evt.target.value)
}

const sendCreateTodo = () =>{
emit('sendCreateTodo')
}
</script>

<template>
<div>
<div v-for="todo in todos" :key="todo.id" data-test="todo">
{{ todo.text }}
</div>
<input
type="text"
placeholder="Add"
:value="newTodo"
@input="emitNewTodoValue"
/>
<button @click="sendCreateTodo">新增</button>
</div>
</template>

Vue v-model指令,值和資料屬性中的值之間建立雙向資料綁定。

1
2
3
4
5
6
7
8
9
10
11
12
<script setup lang="ts">
import { ref } from 'vue'

const value = ref('')
</script>

<template>
<div>
<input v-model="value" type="text" />
<p>Value: {{ value }}</p>
</div>
</template>

v-model.lazy => 減少了 v-model 嘗試與 Vue 實例同步的次數 - 在某些情況下,可以顯著提高效能

v-model.number => 確保我們的值作為數字處理的一種方法是使用修飾符

v-model.trim => 在傳回值之前刪除前導或尾隨空格。

v-model.trim.lazy => 尾隨和前導空格將被刪除

參考資料
參考資料

Vite Vue List

子組件 Nav.vue單元測試

父組件

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
<template>
<Nav
:navLists="navLists"
:pageNameActive="pageNameActive"
@sendMenuClickActive="menuClickActive"
/>
<main>
<KeepAlive>
<router-view />
</KeepAlive>

</main>
<footer>
<div class="reserved">LaraHuang 版權所有</div>
</footer>

</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Nav from '../components/Nav.vue';

import {navType} from '../types/nav'

const navLists = ref<navType[]>([
{ id_set: '001', title: 'Home', href: '/' ,icon: 'icon-location2' },
{ id_set: '002', title: 'List', href: '/list',icon: 'icon-cog' },
{ id_set: '003', title: 'Pinia', href: '/pinia',icon: 'icon-cog' },
])
const pageNameActive=ref<string>('001');
const menuClickActive =(item:navType)=>{
// pageNameActive.value= localStorage.getItem('pageName')as string
pageNameActive.value = item.id_set;
}
</script>

測試子組件如下Nav.vue

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
<template>
<div class="flex justify-center header">
<a href="/me"> <b class="flex justify-center"><h1>Lara 單元測試</h1></b> </a>
</div>
<ul class="nav flex justify-center h-12 leading-10 border_bottom_solid_2 bg_c6e2ff">
<li class="flex mx-2"
v-for="(item,id_set) in navLists" :key="id_set"
:class="pageNameActive == item.id_set ? 'isActive' : ''"
@click="sendMenuClickActive(item)"
>
<router-link :to="item.href" data-test="button" >
{{ item.title }}
</router-link>
</li>
</ul>
</template>

<script setup lang="ts">
import {navType} from '../types/nav'
const props = defineProps({
pageNameActive:{type:String},
navLists:{type:Object}
})
const emit = defineEmits(['sendMenuClickActive'])
const sendMenuClickActive=(_item:navType)=>{
emit('sendMenuClickActive',_item)
}
</script>
mount:掛載。
props:父傳子。
data-test:在html綁定。
findAll():與 類似find,傳回數組DOMWrapper。
expect(value):攥寫每一筆測試時都會使用 expect(value) 和匹配器 (matcher) 來『斷言』某個值,expect (value) 的參數 value 會是程式碼產生的值
toContain('C'):包含
findComponent():尋找 Vue 元件實例,VueWrapper如果找到則傳回。否則返回ErrorWrapper。
emitted():子傳遞給父元件
toHaveLength(Number):Length數量
toEqual():等於
toHaveProperty():屬性的值
測試內容 describe:是用來將一至多組有相關的測試組合在一起的區塊。
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
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Nav from '../Nav.vue'
//Nav.vue 從父元件frontLayout.vue獲得以下陣列
describe('Nav.vue 從父元素 frontLayout.vue 取得以下陣列', () =>
{
const navLists = [
{ id_set: '001', title: 'Home', href: '/' ,icon: 'icon-location2' },
{ id_set: '002', title: 'List', href: '/list',icon: 'icon-cog' },
{ id_set: '003', title: 'Pinia', href: '/pinia',icon: 'icon-cog' },
]
const pageNameActive = '001';
let wrapper
if (typeof document !== 'undefined') {
wrapper = mount(Nav, {
props: {
navLists: navLists,
pageNameActive: pageNameActive
}
})
}

it('navLists資料是否正確渲染訊息? ', () =>
{
if (typeof document !== 'undefined') {
//尋找所有 li標籤
const items = wrapper.findAll('li');

expect(items.length).toBe(items.length);
expect(items[0].text()).toBe('Home');
expect(items[1].text()).toBe('List');
expect(items[2].text()).toBe('Pinia');
}


});
it('Active 傳遞初始值時渲染', () => {
expect(pageNameActive).toMatch(pageNameActive);
})

it('點擊時發出當前資料的事件,Li會新增isActive Class', async() =>
{
if (typeof document !== 'undefined') {
const items = wrapper.findAll('li');
const li = wrapper.find('li');
// 點擊按鈕
await wrapper.get('[data-test="button"]').trigger('click')
//斷言唯一碼必想等
expect(pageNameActive).toMatch(items[0].id_set);
expect(pageNameActive).toMatch(items[1].id_set)
expect(pageNameActive).toMatch(items[2].id_set)
// 斷言li 會包含一個isActive Class
expect(wrapper.get('li').classes()).toContain('isActive')
}
})
it('按一下時檢索當前資料的事件', async () =>
{
if (typeof document !== 'undefined') {
// 點擊按鈕
await wrapper.get('[data-test="button"]').trigger('click')
// 子傳父到sendMenuClickActive 函式
expect(wrapper.emitted()).toHaveProperty('sendMenuClickActive')
}
})
})

Vite Vue 單元測試attributes 判斷屬性

describe:是用來將一至多組有相關的測試組合在一起的區塊。

attributes:判斷屬性

attributes:判斷屬性。
classes:尋找 class 屬性,語法查詢的話將得到一個陣列的結果。。
target:尋找target 屬性。
mount:掛載。
data-test:在html綁定。
props:父傳子。
find():使用 Document.querySelector() 的語法,find() 沒有找到目標元素時不會拋出錯誤。
expect(value):攥寫每一筆測試時都會使用 expect(value) 和匹配器 (matcher) 來『斷言』某個值,expect (value) 的參數 value 會是程式碼產生的值
toContain('C'):包含

案例父傳子attributes判斷屬性 單元測試

defineProps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//html
<template>
<a class="link A C B"
data-test="link"
:href="href" :target="target">點擊</a>
</template>

<script setup lang="ts">
const props = defineProps({
href: { type: String ,required: false },
target: { type: String, required: false },
})

</script>
單元測試
引入描述、它、斷言 =>引入測試mount程式 =>引入組件 =>定義 =>斷言
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
//引入描述、它、斷言
import { describe, it, expect } from 'vitest'
//引入測試實用程式
import { mount } from '@vue/test-utils'
//引入元件
import AButton from '../A.vue'

describe('A標籤', () =>
{
const href = 'https://ithelp.ithome.com.tw/'
const target = '_blank'
// 定義 wrapper掛載AButton元件的父傳子資料
let wrapper
if (typeof document !== 'undefined') {
//掛載AButton元件,父傳子
wrapper = mount(AButton, {
props: {
href: href,
target: target
}
})
}

it('取得A標籤所有訊息,A標籤樣式是否包含link', () =>
{
if (typeof document !== 'undefined') {
//取得A標籤attributes所有訊息
wrapper.find('[data-test="link"]').attributes()
console.log('取得所有A標籤訊息', wrapper.find('[data-test="link"]').attributes())
//斷言A標籤class是否包含link
expect(wrapper.find('[data-test="link"]').classes()).toContain('link')
}
})

it('取得A標籤所有class訊息', () =>
{
if (typeof document !== 'undefined') {
//A標籤class是否包含link
expect(wrapper.find('[data-test="link"]').classes()).toContain('link')

//斷言A標籤class是否包含A
expect(wrapper.find('[data-test="link"]').classes()).toContain('A')

//斷言A標籤class是否包含C
expect(wrapper.find('[data-test="link"]').classes()).toContain('C')

//斷言A標籤class是否包含B
expect(wrapper.find('[data-test="link"]').classes()).toContain('B')

//斷言取得A標籤所有class訊息
console.log('取得A標籤所有class訊息', wrapper.find('[data-test="link"]').classes())
//[ 'link', 'A', 'C', 'B' ]
}
})
})

Vue 單元測試text()& html()內容物

Vite Vue 單元測試text()& html()內容物

describe:是用來將一至多組有相關的測試組合在一起的區塊。

text()& html()內容物

mount:掛載。
data-test:在html綁定。
find():使用 Document.querySelector() 的語法,find() 沒有找到目標元素時不會拋出錯誤。
text()text內容物。
html()html內容物。

案例 text()& html()內容物 單元測試

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//html
<template>
<div data-test="content">
Root
<childComponent :title="title"/>
<childComponent :title="title"/>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import childComponent from './Test.vue'
const title =ref<string>('這是測試')
</script>
單元測試
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
//引入描述、它、期望
import { describe, it } from 'vitest'
//引入測試實用程式
import { mount } from '@vue/test-utils'
//引入元件
import Content from '../Content.vue'

describe('Content', () =>
{
if (typeof document !== 'undefined') {
const wrapper = mount(Content)
}
it('正確渲染text', () =>{
if (typeof document !== 'undefined') {
wrapper.find('[data-test="content"]').text()
console.log(wrapper.find('[data-test="content"]').text())
}
})
it('正確渲染Html', () =>{
if (typeof document !== 'undefined') {
wrapper.find('[data-test="content"]').html()
console.log(wrapper.find('[data-test="content"]').html())
}
})
})


Vue 單元測試attributes 判斷屬性

Vite Vue 單元測試

describe:是用來將一至多組有相關的測試組合在一起的區塊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { describe, it} from 'vitest'
import { mount } from "@vue/test-utils"

const number = 2

describe('Number', () => {
test('is 2', () => {
expect(number).toBe(2)
})

test('is even', () => {
expect(number % 2).toBe(0)
})
})
  • attributes:判斷屬性。
  • class:尋找 class 屬性
    target:尋找target 屬性
    1
    2
    3
    4
    <template>
    <a data-test="link" href="https://ithelp.ithome.com.tw/" target="_blank">ithelp</a>
    </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
        /引入描述、它、期望
    import { describe, it, expect,expectTypeOf } from 'vitest'
    //引入測試實用程式
    import { mount } from '@vue/test-utils'
    //引入元件
    import AButton from '../A.vue'

    describe('A標籤', () =>
    {
    const href = 'https://ithelp.ithome.com.tw/'
    const target = '_blank'

    let wrapper
    if (typeof document !== 'undefined') {
    wrapper = mount(AButton, {
    props: {
    href: href,
    target: target
    }
    })
    }
    it('取得A標籤所有訊息,A標籤樣式是否包含link', () =>
    {
    if (typeof document !== 'undefined') {
    wrapper.find('[data-test="link"]').attributes()
    console.log('取得所有A標籤訊息', wrapper.find('[data-test="link"]').attributes())
    // 屬性{ href: 'https://ithelp.ithome.com.tw/', 'target': '_blank' }
    expect(wrapper.find('[data-test="link"]').classes()).toContain('link')
    // class樣式link
    }
    })
    })
    <div><b>classes:</b>語法查詢的話將得到一個陣列的結果</div>
      <div>
      
    1
    wrapper.find('[data-test="wrap"]').classes()
    </div> </li> <li><b>expect(value):</b>攥寫每一筆測試時都會使用 expect(value) 和匹配器 (matcher) 來斷言某個值,expect (value) 的參數 value 會是程式碼產生的值</li> <li><b> toBe </b>是一個匹配器</li> <li><b> html() </b>回傳元件的 HTML</li> <li><b> toContain() </b>檢查一個字符串是否是另一個字符串的子字符串,也可檢查一個項目是否在 Array 中。</li> <li><b> get() </b> get 方法來搜索現有元素:如果 get() 沒有找到目標元素,它會拋出錯誤並導致測試失敗。如果找到的話則會回傳一個 DOMWrapper。</li> <li><b>find()</b>和 get() 很像,一樣是使用 Document.querySelector() 的語法,不過差別在於 find() 沒有找到目標元素時不會拋出錯誤。</li> <li><b>exist()</b> 檢查元素是否存在。</li> <li><b> text() </b> 回傳元素的文本內容 (text content)。</li> <li><b>isVisible ()</b>專門用來檢查元素是否為隱藏的狀態:元素或元素的祖先中有 display: none、visibility: hidden、opacity:0 的樣式,收合的 <details> 標籤中,具有 hidden 屬性</li> <li><b>trigger ()</b>可以用來觸發 DOM 事件,例如 click、submit 或 keyup 等操作,值得注意的是, trigger 回傳的是一個 Promise,也因此我使用了 async & await 的方式來等待 promise resolve。</li> <li><b>emits()</b>事件通常是由子元件向父元件所觸發的</li> <li><b>vm</b>是 VueWrapper 的一個屬性,我們可以透過 vm 來取得 Vue instance,如此一來就能再透過 vm 取得 count 變數了。</li> <li><b>emitted() </b>emitted() 會回傳一個紀錄元件發出的所有事件的物件,其中也包含著 emit 的參數。</li> <li><b>toHaveProperty()</b>jest 有提供一個 toHaveProperty 的匹配器 (matcher),可以用來檢查物件中是否存在某屬性。</li> <li><b>toEqual()</b>匹配器會去比較物件的所有屬性或陣列的所有元素是否相等。</li>

mount:透過 mount() 產生一個已經掛載 (mounted) 和渲染完的元件(Wrapper),並對其進行操作和斷言。

mount的第二個參數是可以用來定義元件的狀態 (state) 配置,例如 props, data, attrs 等等,因此這次我們就傳入 data 覆蓋掉元件中的預設值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'

describe('HelloWorld', () =>
{
//1+1 應該是2
it('1 + 1 should be 2', () => {
expect(1 + 1).toBe(2)
})
//
it('正確渲染', () => {
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
expect(wrapper.text()).toContain('Hello Vitest')
})
it('在此測試檔案中使用 jsdom', () => {
const element = document.createElement('div')
element.innerHTML = '<p>Hello, HTML!</p>'
expect(element.innerHTML).toBe('<p>Hello, HTML!</p>')
})
})

shallowMount():透過 shallowMount 產生的 wrapper 元件,如果它有子元件的話,子元件不會被解析渲染,也不會觸發子元件內的程式碼,而是會用 stub 來替代。

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 { mount, shallowMount } from '@vue/test-utils'

const Child = {
template: "<div>Child component</div>"
}

const Parent = {
template: "<div><child /></div>",
components: {
Child
}
}

describe('shallowMount example', () => {
test('test', () => {
const childShallowWrapper = shallowMount(Child)
const childMountWrapper = mount(Child)
console.log(childShallowWrapper.html())
console.log(childMountWrapper.html())

const parentShallowWrapper = shallowMount(Parent)
const parentMountWrapper = mount(Parent)
console.log(parentShallowWrapper.html())
console.log(parentMountWrapper.html())
})
})

執行結果

善用data-test

Unit test透過 get() 或 find() 來尋找目標的元素,在元件內綁定元件

1
2
3
4
5
6
7
8
9
10
11
12
import { mount } from '@vue/test-utils'

const Component = {
template: '<div data-test="target">dataset</div>'
}

test('render dataset', () => {
const wrapper = mount(Component)

expect(wrapper.get('[data-test="target"]').text()).toBe('dataset')
})

表單單元測試

1
2
3
4
5
6
7
8
9
10
<template>
<div>
<input type="email" v-model="email" data-test="email" />
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
const email = ref<string>('')
</script>

測試=>輸入元素的值應該是 my@mail.com

1
2
3
4
5
6
7
8
test('輸入元素的值應該是 my@mail.com', async () => {
const wrapper = mount(Component)
const input = wrapper.get('[data-test="email"]')

await input.setValue('my@mail.com')

expect(input.element.value).toBe('my@mail.com')
})
  • setValue()要更改表單元素的值可以使用 setValue() 方法,setValue 接受一個參數,可以是字串或布林值,並且回傳的是一個 Promise。
  • DOMWrapper透過 get() 或是 find() 成功找到目標元素時都會回傳一個圍繞 Wrapper API 的 DOM 元素的瘦包裝器 (thin wrapper),而它有一個代表著 HTMLElement 的屬性 element,又因為在上面的情況目標元素為 input tag 所以此時 element 真實的值其實為 HTMLInputElement。

測試 =>設定值後,電子郵件的值應為 my@mail.com

1
2
3
4
5
6
7
test('設定值後,電子郵件的值應為 my@mail.com', async () => {
const wrapper = mount(Component)

await wrapper.get('[data-test="email"]').setValue('my@mail.com')

expect(wrapper.vm.email).toBe('my@mail.com')
})

比較複雜的表單

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
<template>
<form data-test="form" @submit.prevent="submit">
<input data-test="email" type="email" v-model="form.email" />

<textarea data-test="description" v-model="form.description" />

<select data-test="city" v-model="form.city">
<option value="taipei">Taipei</option>
<option value="tainan">Tainan</option>
</select>

<input data-test="subscribe" type="checkbox" v-model="form.subscribe" />

<input data-test="interval.weekly" type="radio" value="weekly" v-model="form.interval" />
<input data-test="interval.monthly" type="radio" value="monthly" v-model="form.interval" />

<button type="submit"
@click="sendSubmit"
>Submit</button>
</form>
</template>

<script setup lang="ts">
const props = defineProps({
form:{type:Object}
})
const emit = defineEmits(['sendSubmit'])
const sendSubmit=()=>{
emit('sendSubmit')
}
</script>
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
<template>
<div>
<Forms
:form="form"
@sendSubmit ="submit"
></Forms>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Forms from '../component/form.vue'
interface formType{
email: string,
description: string,
city: string,
subscribe: boolean,
interval: string,
}
const form = ref<formType>({
email: '',
description: '',
city: '',
subscribe: false,
interval: ''
})
const submit = () => {
emit('submit', form)
}
</script>

表單測試
Case 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
test('填寫表單', async () => {
const wrapper = mount(Component)

const email = 'name@mail.com'
const description = 'Lorem ipsum dolor sit amet'
const city = 'taipei'
const subscribe = true

await wrapper.get('[data-test="email"]').setValue(email)
await wrapper.get('[data-test="description"]').setValue(description)
await wrapper.get('[data-test="city"]').setValue(city)
await wrapper.get('[data-test="subscribe"]').setValue()
await wrapper.get('[data-test="interval.weekly"]').setValue()

expect(wrapper.vm.form).toEqual({
email,
description,
city,
subscribe,
interval: 'weekly'
})
})

Case 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
test('提交表單', async () => {
const wrapper = mount(Component)

const email = 'name@mail.com'
const description = 'Lorem ipsum dolor sit amet'
const city = 'taipei'
const subscribe = true

await wrapper.get('[data-test="email"]').setValue(email)
await wrapper.get('[data-test="description"]').setValue(description)
await wrapper.get('[data-test="city"]').setValue(city)
await wrapper.get('[data-test="subscribe"]').setValue(subscribe)
await wrapper.get('[data-test="interval.monthly"]').setValue()

await wrapper.get('[data-test="form"]').trigger('submit.prevent')

expect(wrapper.emitted('submit')[0][0]).toEqual({
email,
description,
city,
subscribe,
interval: 'monthly'
})
})
  • emitted()會回傳一個紀錄元件發出的所有事件的物件,其中也包含著 emit 的參數

prop & Computed

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
import { ref, computed } from 'vue'

const Component = {
template: `
<div>
<input data-test="password" v-model="password">
<p v-if="showError && isError" data-test="errorMsg">Password must be at least {{minLength}} characters.</p>
</div>
`,
props: {
minLength: {
type: Number,
required: true
},
showError: {
type: Boolean,
default: true
}
},
setup (props) {
const password = ref('')
const isError = computed(() => password.value.length < props.minLength)

return {
isError,
password
}
}
}
  • beforeEach() 在每一個 test() 執行前運行的一個函式,常會用來初始化 wrapper 。
  • setProps() 在 wrapper 生成後,動態的改變 props 的值。
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
describe('Props & Computed', () => {
let wrapper
const minLength = 6
beforeEach(() => {
wrapper = mount(Component, {
props: {
minLength
}
})
})

// Case 1: 密碼在大於或等於最短長度限制時,不會出現錯誤訊息。
test(`not renders an error if length is more than or equal to ${minLength}`, async () => {
await wrapper.get('[data-test="password"]').setValue('123456')

expect(wrapper.find('[data-test="errorMsg"]').exists()).toBe(false)
})

// Case 2: 密碼少於最短長度限制時,出現錯誤訊息。
test(`renders an error if length is less than ${minLength}`, async () => {
await wrapper.get('[data-test="password"]').setValue('12345')

expect(wrapper.html()).toContain(`Password must be at least ${minLength} characters`)
})

// Case 3: 當 showError 為 false 時,不顯示錯誤訊息。
test('not renders an error if showError is false ', async () => {
await wrapper.get('[data-test="password"]').setValue('12345')

expect(wrapper.html()).toContain(`Password must be at least ${minLength} characters`)

await wrapper.setProps({ showError: false })

expect(wrapper.find('[data-test="errorMsg"]').exists()).toBe(false)
})
})

Node express Vite Vue

創建 Vite Vue

Vite 安裝與環境變數設定

相容性說明

Vite 需要 Node.js 版本 18+。 20+。 但是,某些模板需要更高的 Node.js 版本才能運作,如果您的套件管理器發出警告,請升級。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
npm create vite-extra@latest 專案名稱

create-vite@5.5.2
Ok to proceed? (y)
? Select a framework: › - Use arrow-keys. Return to submit.
Vanilla
❯ Vue
React
Preact
Lit
Svelte
Solid
Qwik
Others

參考官網

1
2
3
4
cd vite-project
npm install
npm run dev

專案架構

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
├── .vscode
├── public
│ └── vite.svg
├── src
│ ├── App.vue
│ ├── assets
│ │ └── vue.svg
│ ├── components
│ │ └── HelloWorld.vue
│ ├── main.js
│ └── style.css
├── .gitignore
├── index.html
├── package.json
├── package-lock.json
├── README.md
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.js

安裝完成
gitHub上的說明

gitHub上的說明(vite ^5.4.1”)

創建 Express 伺服器

安裝 express 與nodemon concurrently

1
2
npm install express
npm install --save-dev nodemon concurrently

gitHub上的說明:安裝 express 與nodemon concurrently
專案新增server/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
import express from "express";

const port = process.env.PORT || 3000;

const app = express();

app.get("/api/v1/hello", (_req, res) => {
res.json({ message: "Hello, world!" });
});

app.listen(port, () => {
console.log("Server listening on port", port);
});

修改package.json設定檔,新增一個主檔案並將 dev 命令替換為以下內容

1
2
3
4
5
6
7
8
9
10
11
   "version": "1.0.1",
"type": "module",
+ "main": "server/index.js",
"scripts": {
- "dev": "vite",
+ "dev:frontend": "vite",
+ "dev:backend": "nodemon server/index.js",
+ "dev": "concurrently 'npm:dev:frontend' 'npm:dev:backend'",
+ "build": "vite build",
"preview": "vite preview"
},

這樣Vite伺服器和Nodemon就會並行運作。
運行來啟動伺服器

1
npm run dev

如果我們訪問http://localhost:3000/api/v1/hello
現在有一個前端和一個後端正在運行,但它們還沒有互相溝通

1
2
3
{
"message": "Hello, world!"
}

gitHub上的說明:測試api/v1/hello運行

連接客戶端和伺服器

安裝ejs

1
npm install ejs

新增server/homepageRouter.js

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
import express from "express";
import fs from "fs/promises";
import path from "path";

const router = express.Router();
//
const environment = process.env.NODE_ENV;

//開發中,獲取所有的數據(來自打包產生dist/manifest.json
router.get("/*", async (_req, res) => {
const data = {
environment,
manifest: await parseManifest(),
};
console.log('data', data)
res.render("index.html.ejs", data);
});

const parseManifest = async () => {
if (environment !== "production") return {};

const manifestPath = path.join(path.resolve(), "dist", "manifest.json");
console.log('manifestPath', manifestPath)
const manifestFile = await fs.readFile(manifestPath);
console.log(' manifestFile', manifestFile)
return JSON.parse(manifestFile);
};

export default router;

homepageRouter.js
新增server/assetsRouter.js,靜態圖片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import express from "express";

const router = express.Router();
//支持的檔案類型
const supportedAssets = ["svg", "png", "jpg", "png", "jpeg", "mp4", "ogv"];

const assetExtensionRegex = () =>
{
//JS 把陣列 Array 所有元素併成字串,且可任意穿插符號的 join()
const formattedExtensionList = supportedAssets.join("|");
//JS Regex 正則表達式
return new RegExp(`/.+\.(${formattedExtensionList})$`);
};

router.get(assetExtensionRegex(), (req, res) => {
res.redirect(303, `http://localhost:5173/src${req.path}`);
});

export default router;

homepageRouter.js

修改server/index.js

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
import express from "express";
import path from "path";
import homepageRouter from "./homepageRouter.js";
import assetsRouter from "./assetsRouter.js";

const port = process.env.PORT || 3000;
//載入為靜態目錄
const publicPath = path.join(path.resolve(), "public");
const distPath = path.join(path.resolve(), "dist");

const app = express();

app.get("/api/v1/hello", (_req, res) => {
res.json({ message: "Hello, Lara!" });
});

//如果是生產階段就連結到dist/,否則連接到public與/src
if (process.env.NODE_ENV === "production") {
app.use("/", express.static(distPath));
} else {
app.use("/", express.static(publicPath));
app.use("/src", assetsRouter);
}

//將路由器連接到 Express 應用程式
app.use(homepageRouter);

app.listen(port, () => {
console.log("Server listening on port", port);
});

路由器

修改src/components/HelloWorld.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const serverHello = ref({})
fetch('/api/v1/hello')
.then((res) => res.json())
.then(({ message }) => {
serverHello.value = message
})
</script>

<template>
<h2>{{ serverHello }}</h2>
</template>

HelloWorld.vue

刪除index.html,在根目錄新增一個 views/index.html.ejs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
<% if (environment === 'production') { %>
<link rel="stylesheet" href="<%= manifest['src/main.css'].file %>" />
<% } %>
</head>
<body>
<div id="app"></div>
<% if (environment === 'production') { %>
<script type="module" src="<%= manifest['src/main.ts'].file %>"></script>
<% } else { %>
<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/src/main.ts"></script>
<% } %>
</body>
</html>

index.html.ejse

生產中運行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
// 新增打包
build: {
manifest: true,
rollupOptions: {
input: "./src/main.ts",
},
},
});

vite.config.ts 新增打包設定

執行打包,

1
npm run build

產生dist後,在.vite將manifest.json移動到dist根目錄,並修改manifest.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"src/assets/vue.svg": {
"file": "vite.svg",
"src": "src/assets/vue.svg"//物件名與src名相同
},
"src/main.css": {
"file": "assets/main-CYBnthfA.css",//打包後的檔案
"src": "src/main.css"//物件名與src名相同
},
"src/main.ts": {
"file": "assets/main-CpTINVMW.js",//打包後的檔案
"name": "main",
"src": "src/main.ts",//物件名與src名相同
"isEntry": true,
"css": [
"assets/main-CYBnthfA.css"//打包後的檔案
]
}
}

demo