---
###### tags: `vue`
---
{%hackmd S1A4SV2pL %}

Vue3 升級指南
===
[TOC]
# 序
:::warning
文檔會持續更新
:::
此本筆記為升級指南,不會依依講解個個 api,只會講解你在升級時會遇到的 api 處理或者是更適合 Vue3 的實踐方式,若要詳細的 api 文檔,請查閱[官方文檔](https://v3.vuejs.org/)
> 事例代碼含部分 ts 代碼
[延伸閱讀 Vue3 隨寫隨記](https://hackmd.io/a-4qp8LtRKOLCvnCKcRM-g?view)
~~碼字不易,心存感激~~
---
# 設計理念
[官方文檔](https://vue3js.cn/vue-composition/) | [One Piece](https://vue3js.cn/)
---
# Vite
Vue 作者提供的新打包工具,使用如 snowpack 的 es module 理念來進行運行,所以可以達到秒啟動,不用像 vue-cli 要進行編譯後啟動
* **目前僅支援 vue3,所以不能使用與 vue3 不兼容的庫**
* **引入檔案必須掛載副檔名** e.g. ✔`./Comp.vue` 、 ❌`./Comp`
[vite](https://github.com/vitejs/vite#dev-server-proxy)
## 建立專案
```shell=
npm init vite-app <project-name>
# OR
yarn create vite-app <project-name>
```
雖然是為 vue3 使用者打造,但依然可以搭配其他框架可以使用,添加選項符 `npm init vite-app --template react` 或 `--template preact`
## css 預處理
### 安裝
```shell=
yarn add -D sass
```
### 使用
這下就可以使用啦!
* 使用 `style` 標籤
```html=
<style lang="scss">
/* use scss */
</style>
```
或者 import 到 js 裡
```javascript=
import './style.scss'
```
#### 設定全局參數
可以在 [Config File](https://github.com/vitejs/vite#config-file) 裡寫入
```javascript=
// vite.config.js
export default {
cssPreprocessOptions: {
less: {
modifyVars: {
'preprocess-custom-color': 'green'
}
}
}
}
```
## Typescript
天生支持 `.ts` 和 `<script lang="ts">`,不過為了有更好的體驗,建議還是配置一些檔案
### 類型推導
為了讓 ts 有更好的類型推導,請在 src 下創建 `shims-vue.d.ts`
```typescript=
declare module "*.vue" {
import { defineComponent } from "vue";
const Component: ReturnType<typeof defineComponent>;
export default Component;
}
```
## alias
### vite.config.js
可以在此配置 alias,預設並不會像 vue-cli 會添加 `@` alias,這必須手動添加
```javascript=
const path = require('path')
export default {
port: 9184,
alias: {
// 注意:vite 必須以 / 開頭
'/@/': path.resolve(__dirname, './src'),
},
}
```
#### 補充說明
`vite` 運行時默認查找 vite.config.js 檔案,但如果想要個別配置,可以使用 `--cofig` 來配置
```shell=
vite --config my.config.js
```
### Jetbrains & typescript
Jetbrains IDEA 無法識別 vite.config.js alias 配置,導致仍用該檔目錄去找引入檔,為了解決這問題可以配置 `tsconfig.json`
```json=
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"/@/*": ["./src/*"]
}
}
}
```
## JSX
直接支持,不須額外配置
```javascript=
import { createApp } from 'vue'
function App() {
return <Child>{() => 'bar'}</Child>
}
function Child(_, { slots }) {
return <div onClick={() => console.log('hello')}>{slots.default()}</div>
}
createApp(App).mount('#app')
```
## dev server proxy
配置跟 vue-cli 差不多
```javascript=
// vite.config.js
export default {
proxy: {
// string shorthand
'/foo': 'http://localhost:4567/foo',
// with options
'/api': {
target: 'http://jsonplaceholder.typicode.com',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '')
}
}
}
```
## 環境變數
預設的兩個 modes:
* `development` `vite serve`
* `production` `vite build`
可詳盡配置可以查看[Modes and Environment Variables](https://github.com/vitejs/vite#modes-and-environment-variables)
---
# Vue-cli
使用方式跟 2 一樣,只要升級即可
* ts 開箱即用不必配置
[vue-cli](https://cli.vuejs.org/)
## 安裝
- 沒有安裝過的小夥伴請看此
```shell=
npm install -g @vue/cli
# OR
yarn global add @vue/cli
```
- 有安裝過舊版的看此
```shell=
vue upgrade
```
## 創建
請確保版本在 `v4.5 以上`
```shell=
# 查看版本號是否大於 4.5,沒請會看上一步,否則繼續往下看
vue --version
vue create awesome-vue
# OR
vue ui
```
---
# Vue
## Fragment
* `vue2` template 需要主節點
* `vue3` template 不用主節點
```html=
<!-- vue2 -->
<template>
<div> something... </div>
</template>
<!-- vue3 -->
<template>
something...
</template>
```
## defineComponent
非使用 ts 的小夥伴請略過,使用 ts 請將每個組件都用 `defineComponent` 包裹建立,這樣才能得到類型檢查
```typescript=
// defineComponent 僅用來綁定類型用,所以 js 不用套娃
export default defineComponent({
name: 'v-component'
})
```
## Component Options
* `vue2` 將要處理的邏輯統一放在個別的 option 裡
* `vue3` 全部寫在 `setup` 裡,使用 hook 來達成所有原 vue2 的 options 功能
[setup](https://v3.vuejs.org/guide/composition-api-introduction.html#setup-component-option) | [ref](https://v3.vuejs.org/guide/reactivity-fundamentals.html#creating-standalone-reactive-values-as-refs)
### 定義
```javascript=
// vue2
<div>count {{count}}, double {{double}}</div>
<button @click="increase">👍+1</button>
export default {
data(){
return {
count: 1
}
},
computed: {
double() {
return this.count * 2
}
},
methods: {
increase() {
this.count ++
}
}
}
// vue3
<div>count {{count}}, double {{double}}</div>
<button @click="increase">👍+1</button>
export default {
setup() {
const count = ref(1)
const double = computed(() => count.value * 2)
const increase = () => count.value++
// 自行控制要消費的值給 template
return {
count, double
}
}
}
```
### reactive 與 toRefs
`ref` 為 state 的新取代方案,但是取值都要使用 `.value` 來處理,如果想要省略或是包裹邏輯,可以採用 `reactive` 來處理
[reactive](https://v3.vuejs.org/guide/reactivity-fundamentals.html#declaring-reactive-state)
```javascript=
// ref
<div>count {{count}}, double {{double}}</div>
setup() {
const count = ref(1)
const double = computed(() => count.value * 2)
return { count, double }
}
// reactive
<div>count {{counter.count}}, double {{counter.double}}</div>
setup() {
const counter = reactive({
count: 1,
// 注意了啊!reactive 裡不用再用 .value 來取值
double: computed(() => counter.count * 2)
})
return { counter }
}
```
但是偷雞的同學會看到,改成 reactive 豈不是要打更多代碼,那這樣我寧可放棄可讀性,選擇 ref 啊,所以這個時候可能會想到這麼使用
```javascript=
// 沿用上方的 counter
return { ...counter }
// or
return { count: counter.count, double: counter.double }
```
這時候你會發現他們失去了響應性,為什麼會這樣呢?因為 reactive 的值皆是 proxy 代理的魔法值,如果將其從中取出,將會失去 proxy 的代理處理(下方會講解到),這時候就可以使用 `toRefs`,這時候就會將所有值轉換成 `ref`
```javascript=
return { ...toRefs(counter) }
```
### 注意
如果是使用 ts 的小夥伴要注意啦!
```typescript=
// 如果沒有 computed 等內容可以直接類型推斷
const r1 = reactive({
count: 1,
})
// 這個可不行,需要傳入 Type 來告訴 ts 類型
const r2 = reactive({
count: 1,
// 會自動將類型推斷成 any,所以會有問題
double: computed(() => r2.count * 2)
})
// 解決方法
interface R3 {
count: number
double: number
}
const r3: R3 = reactive({
count: 1,
double: computed(() => r3.count * 2)
})
```
### 總結
使用 ref 還是 reactive 可以選擇這樣的準則
- `第一` 就像剛才的原生 javascript 的代碼一樣,像你平常寫普通的 js 代碼選擇原始類型和對像類型一樣來選擇是使用 ref 還是 reactive
- `第二` 所有場景都使用 reactive,但是要記得使用 toRefs 保證 reactive 對象屬性保持響應性
## $set
- `vue2` 物件的 key 沒有在初始化定義,那將會使得響應追蹤失效,必須使用 $set 等方式將響應連結
- `vue3` 底層採用 [Proxy](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Proxy) 來處理,導致能監聽到變化並提升性能,換來的是 IE 支援度下降
```javascript=
// Vue2
<div>{obj.a}</div>
export default {
data: {
obj: {}
},
created() {
// 這麼寫資料會動但畫面不動
// this.obj.a = 123
// 所以這時精明的小夥伴就會用這個方式處理此問題
this.$set(this.obj, 'a', 123)
}
}
// Vue3
// 😎 沒有此問題
```
### 原理
因為 vue2 使用的是 defineProperty 來進行數據攔截,那麼你新塞的 key-value 是感應不到的,而 vue3 的 Porxy 是更高級的代理,可以監聽到
```javascript=
Object.defineProperty(obj, key, {
get() { return null },
set(value) {},
})
// 可以看到 key 是由 ge/setter 取到,可以做到更細粒化的操作
new Proxy(obj, {
get(key) { return null },
set(key, value) {},
})
```
## Render Function (JSX)
**不比較 createElement 寫法,這部分自行去找**
* `vue2` 調用 render 函數寫即可
* `vue3` setup 返回 element
[Render Function API](https://v3.vuejs.org/guide/migration/render-function-api.html)
```javascript=
// vue2
import CustomComponent from '@components/CustomComponent'
export default {
data() {
return {
name: 'hello jsx',
list: [1, 2, 3]
}
},
props: {
id: {
type: Number,
required: true,
default: 1
},
show: {
type: Boolean,
required: true,
default: false
}
},
methods: {
hello() {
console.log('hello!')
},
nativeHello() {
console.log('native hello')
}
},
render() {
const divProps = { class: 'cool-div', style: { color: 'red' } }
// slots
console.log(this.$slots)
//$scopedSlots (含 scoped value 的 slots,需要傳值就用它,否則用 $slots)
console.log(this.$scopedSlots)
// directives
const directives = [
{ name: 'my-dir', value: 123, modifiers: { abc: true } }
]
return (
<div>
/*
<h1 v-if="id===1">
<slot></slot>
</h1>
<h2 v-if="id===2">
<slot></slot>
</h2>
...
*/
<div domPropsInnerHTML="<h${this.id}>${this.$slots.default[0].text}</h${this.id}>"></div>
// 解構 props
<div {...divProps} />
// v-model
<input vModel={this.name} />
// 使用 component
<CustomComponent />
// v-if, else
{this.show ? <strong /> : <b />}
// v-for
<ul>
{this.list.map(index => <li :key="index">{index}</li>)}
</ul>
// 事件綁定及原生事件綁定
<input
onClick={this.hello}
nativeOnClick={this.nativeHello} />
// 事件修飾符
<input onKeydown:enter={() => {}} />
// ref
<div ref="DivRef"></div>
// v-for ref
<ul>
{this.list.map(index => <li
:key="index"
ref="ItemRef"
refInFor>{index * 2}</li>)}
</ul>
// directives
<div {...{ directives }}/>
// default slot
{this.$slots.default || 'slot default is null'}
// custom slot
{this.$slots.custom}
// slot scoped
{{this.$scopedSlots.custom({title: 'hello'})}}
/*
組件調用寫法 (此用 Comp 表示此組件)
<Comp
{...{
$scopedSlots: {
// 呼叫具名的 custom slot
custom: res => {
return res.title // hello
}
}
}}
>
<template slot="default">default slot</template>
</Comp>
*/
</div>
)
}
}
// 函數式組件 <template functional />
// 申明 functional 即可,這樣就沒有 this 實例了
export default {
functional: true,
render(_, context) {
// props
const props = context.props
// children VNode 子節點數組,可以當作 slot default
const children = context.children
// slots
const slots = context.slots
// data 傳遞給組件的數據對象,也就是父組件給子組件增加的屬性
const data = context.data
// context...
return context.children
}
}
// vue3
// 待更新(有不小的改動),使用時記得引入 vue-next-jsx 進行搖樹優化
setup() {
return <div>hello vue3 jsx</div>
}
```
## $parent
:::danger
容易寫出 :-1: code,故不展開介紹,請提供思路
:::
1. [getCurrentInstance](https://v3.vuejs.org/api/composition-api.html#getcurrentinstance)
2. provide/inject 處理
## 事件監聽器 $on, $once, $off
* `vue2` 可以使用以上語法達到事件監聽,查看[程序化的事件侦听器](https://cn.vuejs.org/v2/guide/components-edge-cases.html#%E7%A8%8B%E5%BA%8F%E5%8C%96%E7%9A%84%E4%BA%8B%E4%BB%B6%E4%BE%A6%E5%90%AC%E5%99%A8)
* `vue3` 廢棄,要使用替代方案:[mitt](https://github.com/developit/mitt) (官方建議)
### 使用情境
在製作語意化組件時,很常遇到這個問題,父(v-form)該如何與子(v-form-input)通信?這時候就可以使用 "事件監聽器" 來處理
```html=
<v-form @submit="onSubmit">
<v-form-input type="text" v-model="account" />
<v-form-input type="password" v-model="password" />
</v-form>
```
### 代碼演示
```javascript=
// vue2
// v-form-input
this.$parent.emit('item-created', validateInput)
// v-form
const validateFuncArr = []
this.$on('item-created', func => validateFuncArr.push(func))
// vue3
// 因為被廢棄,所以講解 mitt 的作法
export const emitter = mitt()
// v-form
export default defineComponent({
setup() {
let validateFuncArr = []
// 添加監聽
emitter.on('form-item-created', callback)
onUnmounted(() => {
// 删除監聽
emitter.off('form-item-created', callback)
funcArr = []
})
}
})
// v-form-input
onMounted(() => emitter.emit('form-item-created', validateInput))
```
## ref attribute
- `vue2` 使用 this.$refs 處理
- `vue3` 則使用如 react 的 ref 方式處理
[ref](https://v3.vuejs.org/api/special-attributes.html#ref)
### 單筆
```javascript=
// vue2
<div ref="Div"></div>
this.$refs.Div // 取得 ref dom
// vue3
<div ref="Div"></div>
setup() {
const Div = ref<null | HTMLElement>(null)
onMounted(() => document.addEventListener('click',
// 使用 ref.value 來取得綁定的 ref dom
() => console.log(Div.value)))
return {
Div
}
}
```
### 多筆
```javascript=
// vue2
// 同單筆不變
// vue3
// 用 ref callback 塞 el 進去
<div
v-for="(it, i) in [1, 2, 3]"
:key="it"
:ref="el => Divs[i] = el">{{it}}</div>
setup() {
const Divs = ref<HTMLElement[]>([])
onMounted(() => document.addEventListener('click',
// [dom...]
() => console.log(Divs.value)))
return {
Divs
}
}
```
## v-model

- `vue2` 僅綁定一個 value
- `vue3` 可以指定綁定的值、多筆 v-model,以及編譯的結果也不同
[v-model](https://v3.vuejs.org/guide/migration/v-model.html#overview)
### 編譯
```html=
<!-- Vue2 上編譯前;下編譯後 -->
<Component v-model="title"></Component>
<Component :value="title" @input="title = $event"></Component>
<!-- Vue3 上編譯前;下編譯後 -->
<Component v-model="title"></Component>
<Component :modelValue="title" @update:modelValue="title = $event"></Component>
<!-- 指定 key -->
<Component v-model:value="title"></Component>
<Component :value="title" @update:value="title = $event"></Component>
<!-- 多項綁定 -->
<Component v-model:title="title" v-model:name="name"></Component>
```
### model option
* `vue2` 可以使用 model option 做到資料自訂更新
* `vue3` ~~廢棄~~
```javascript=
// vue2
model: {
prop: 'value',
event: 'update',
}
// 更新 vlaue
this.$emit('update', data)
// 或者使用編譯的 input 更新
this.$emit('input', data)
// vue3
// 雖然廢棄了 model option,不過還是可以按照編譯結果進行更改
context.emit('update:modelValue', data)
```
## life-cycle 生命週期
* `vue2` 寫在 options 裡
* `vue3` 使用 hook 寫在 setup 裡,並調整原有的命名及廢棄一些
[生命週期](https://v3.vuejs.org/guide/composition-api-lifecycle-hooks.html)
在 setup 中使用的 hook 名稱和原來生命週期的對應關係
* beforeCreate -> **不需要(setup)**
* created -> **不需要(setup)**
* beforeMount -> **onBeforeMount**
* mounted -> **onMounted**
* beforeUpdate -> **onBeforeUpdate**
* updated -> **onUpdated**
* beforeDestroy -> **onBeforeUnmount**
* destroy -> **onUnmounted**
* errorCaptured -> **onErrorCaptured**
* renderTracked -> **onRenderTracked**
* renderTriggered -> **onRenderTriggered**
```javascript=
// vue2
export default {
created() {},
mounted() {},
// ...
}
// vue3
export default {
// 同 beforeCreated + created
setup() {
onMounted(() => { /* do something... */ })
}
}
```
## props
* `vue2` 寫在 props option,使用 this 取值
* `vue3` 寫在 props option,使用 setup `props(first argument)` 取值
```javascript=
// vue2
export default {
props: {
name: String
},
created() {
}
}
// vue3
export default {
props: {
name: String,
}
}
```
### TS
使用 ts 的小夥伴要注意啦!
* 建議使用完整聲明方式,才能追蹤到 prop 類型
* 可以使用 `PropType<T>` 來定義類型
```typescript=
props: {
// 使用物件的方式申明,ts 才能識別出類型
list: {
required: true,
type: Array as PropType<string[]>,
}
}
```
## $emit
* `vue2` 使用 this.$emit 調用,觀看時必須去 methods 翻閱才能知道有哪些 emit function,有礙閱讀
* `vue3` 多出了 emits option 來申明 emit function
[emit](https://v3.vuejs.org/api/options-data.html#emits)
```javascript=
// vue2
// 僅申明再調用處,不方便閱讀
this.$emit('name')
// vue3
// 使用 emit option 申明,方法清晰可見
export default {
// 基礎版
emit: ['on-click'],
// 詳細版,同等於上面
emit: {
'on-click': null
},
// 極致版,定義 payload 及檢測機制(必須回傳布林)
emit: {
'on-click': (payload) => {
if (payload.coin < 10) {
console.error('你錢好少')
return false
}
return true
}
},
setup(props, context) {
// emit 在 context 裡
onMounted(() => emit('on-click', { coin: 6 }))
}
}
```
使用極致版的好處有
- 更方便 IDE 檢測
- 更加清晰可見的文檔
## Vue 全局 API (Global API)
* `vue2` 直接修改 Vue 實例來掛載
* `vue3` 使用局部修改(使用 Vue 實例化後的變量來注入)
:::danger
全局配置的壞處有:
* 在單元測試中,全局配置非常容易汙染全局環境
* 在不同 apps 中,共享一份有不同配置的 Vue 對象,變得相當困難
:::
|2.x Global API| 3.x Instance API (app)|
|-|-|
|Vue.config|app.config|
|Vue.config.productionTip|removed|
|Vue.config.ignoredElements|app.config.isCustomElement|
|Vue.component|app.component|
|Vue.directive|app.directive|
|Vue.mixin|app.mixin|
|Vue.use|app.use|
|Vue.prototype|app.config.globalProperties|
[Global API](https://v3.vuejs.org/guide/migration/global-api.html#a-new-global-api-createapp)
```javascript=
// vue2
Vue.config.ignoredElements = [/^app-/]
Vue.use(/* ... */)
Vue.mixin(/* ... */)
Vue.component(/* ... */)
Vue.directive(/* ... */)
Vue.prototype.customProperty = () => {}
new Vue ({
render: h => h(App)
}).$mount('#app')
// vue3
const app = createApp(App)
app.config.isCustomElement => tag => tag.startsWith('app-')
app.use(/* ... */)
app.mixin(/* ... */)
app.component(/* ... */)
app.directive(/* ... */)
app.config.globalProperties.customProperty = () => {}
app.mount('#app')
```
### 原理
會這麼做的主要原因在於 `Global Api Treeshaking(搖樹)`,這是打包工具 `wepack`、`rollup` 提出的概念,他支持的格式為 es module,es module 可以細粒度的引用導出的內容,簡單來說就是 `去掉不需要的特性,保留需要的特性`
```javascript=
// vue2
import Vue from 'vue'
Vue.nextTick(() => {})
const obj = Vue.observable({})
// vue3
import Vue, { nextTick, observable } from 'vue'
Vue.nextTick // undefined
nextTick(() => {})
const obj = observable({})
```
以下用簡單的範例來解釋他
```javascript=
// hello.js
export function hello(message) {
return 'hello ' + message
}
export function foo(message) {
return 'bar ' + message
}
// 這時候你在使用時,foo 並沒有被用到,那麼打包工具將不會將他打包進去
// 這樣細粒化的方法可以享受 Treeshaking 的優化同時縮小最終代碼的體積
import {hello, foo} from './hello'
alert(hello('frank'))
```
## 異步組件 (Suspense)
- `vue2` 使用工廠函數或 require, import 方法來創建
- `vue3` 使用 Suspense 處理
```javascript=
// vue2
// 使用工廠函數來處理(以下四種方式皆可)
Vue.component('async-comp', resolve => {
setTimeout(() => {
resolve({
template: `<div>i'm async comp</div>`
})
}, 1000)
require(['./async-comp'], resolve)
})
Vue.component('async-comp', resolve => require(['./async-comp'], resolve))
Vue.component('async-comp', () => import('./async-comp'))
const AsyncComponent = () => ({
component: import('./async-comp.vue'),
loading: LoadingComponent,
error: ErrorComponent,
delay: 200,
timeout: 3000,
})
// vue3
const AsyncComponent = {
// 在 setup 裡返回 promise 來表示為異步組件
setup() {
return new Promise(resolve => {
setTimeout(() => {
resolve({
title: 'i\'m async comp'
})
}, 3000)
})
}
}
```
- vue3 接收異步組件
```html=
<!-- 使用 Suspense 來讀取 -->
<Suspence>
<tmeplate #default><AsyncComponent/></tmeplate>
<!-- fallback 為讀取中展示的組件 -->
<tmeplate #fallback><h1>Loading</h1></tmeplate>
</Suspence>
```
### 總結
- slot default 為載入的組件
- slot default 可以傳入多個載入組件,但需要一個跟元素
- slot fallback 為讀取時展示的組件
- 返回為 promise 將為異步組件(可由 async / await 優化)
- 只要一個組件出錯將全部出錯
- 返回時間為最後載入的組件時間
## teleport (vue2 ❌)
`組件注入(瞬移)功能`,通常運用在彈窗等功能上,我們可以知道彈窗等元素是屬於獨立於頁面中的組件,如果沒有將她住在外層,這時候會嵌套在組件下,那麼樣式的汙染將容易出現以及沒有語意性
- `vue2` 要使用 dom、Vue 等騷操作來達成
- `vue3` 直接使用 teleport 注入即可
[teleport](https://v3.vuejs.org/guide/teleport.html#teleport)
```javascript=
// vue2
onOpenDialog() {
new Vue({ /* ... */ })
}
// vue3
<teleport to="#modal">
// 使用 teleport 會自動將組件注入到 #model element
dialog
</teleport>
```
## 非 Prop Attribute ($attrs)
template 用法與 vue2 一致,不過 script 取法就不同了
* `vue2` 使用 this.$attrs
* `vue3` 使用 context.attrs
[$attrs](https://v3.vuejs.org/api/instance-properties.html#refs)
```javascript=
// vue2
<div v-bind="$attrs"></div>
// script 取值
this.$attrs
// vue3
<div v-bind="$attrs"></div>
// script 取值
context.attrs
```
## transition 漸變
待更新
## mixins
- `vue2` 眾所周知,vue2 不該使用此語法,用多了會暴露許多問題
- `vue3` 因使用成員變量,所以不會有 vue2 遇到的問題
[mixins](https://v3.vuejs.org/guide/mixins.html)
```javascript=
// 待更新
```
## inject / provide

:::info
vue2 不了解,所以不舉例,以下只講 vue3 版本的
:::
似於 useContext 與 redux 的關係,有人贊同用此 hook 取代狀態管理,而 inject / provide 與 vuex 也是處於這樣的關係
* `vue3` 全局邏輯復用,子組件可以取得父組件提供的邏輯,似於 react context
以下僅講解 vue3 的 [ineject / provide](https://v3.vuejs.org/guide/component-provide-inject.html#working-with-reactivity)
[延伸閱讀 - 使用 Angular 風格來寫 Vue](https://zhuanlan.zhihu.com/p/212210282)
- 供給者代碼
```javascript=
const StoreSymbol = Symbol()
export function provideStore(store) {
provide(StoreSymbol, store)
}
export function useStore() {
const store = inject(StoreSymbol)
if (!store) {
// 拋出錯誤,不提供 store
}
return store
}
```
- 消費者代碼
```javascript=
// 在根组件中提供 store
const App = {
setup() {
provideStore(store)
},
}
const Child = {
setup() {
const store = useStore()
// 使用 store
},
}
```
### 實現簡易的數據管理
如果項目中需要相當簡單的全局狀態,那麼可以考慮採用此方案,與引入 Vuex 能更有效減少打包體積
* #### store.ts
創建一個簡單的只有 count ref store
```typescript=
interface Store {
count: Ref<number>
}
const storeSymbol = Symbol()
// store 內容
const store: Store = (() => {
const count = ref(0)
return {
count
}
})()
// 供給者
export const createStore = () => provide(storeSymbol, store)
// 消費者
export const useStore = (): Store => inject(storeSymbol)
```
* #### 引入
直接掛在 App.vue 跟節點上,讓所有子組件都能拿到狀態
```typescript=
// App.vue
setup() {
createStore()
}
```
* #### 調用
這任意組件上掛載 count 到畫面上後改變數據,就能發現所有子組件的數據皆有響應更新
```typescript=
// A, B,... 組件調用 {{count}} 都能發現數值改變
setup() {
const store = useStore()
return { ...store }
}
```
## createApp (vue2 ❌)
主要用來將 Vue 實例掛載到 Node 樹裡,但是跟 teleport 那篇講解的 vue2 製作彈窗的方式雷同,也可以使用 createApp 來將組件封裝成函數來調用
[createApp](https://v3.vuejs.org/api/global-api.html#createapp)
```typescript=
// 可以接收兩個參數,VueObj: Vue 實例物件, props: props
createApp(VueObj, props)
```
有著這兩項參數,就能知道該如何封裝一個函數調用組件,如 `createMessage`
### createMessage
假設我們有一個 Message 組件,支援兩個 prop
|propKey|description|
|-|-|
|message|顯示的訊息文字|
|type|顯示的樣式顏色|
那麼我們可以將其封裝成:
```typescript=
const createMessage = (message: string, type: MessageType, timeout = 2000) => {
// 創建一個 Message 組件,並傳遞兩個 prop
const messageInstance = createApp(Message, {
message,
type,
})
// 將組件掛載到 body 中
const mountNode = document.createElement('div')
document.body.appendChild(mountNode)
messageInstance.mount(mountNode)
// timeout 時間後銷毀組件
setTimeout(() => {
messageInstance.unmount(mountNode)
document.body.removeChild(mountNode)
}, timeout)
}
```
## Custom Directives
待更新
## Filters
待更新
## hooks (vue2 ❌)
composition api 提供各式強大的 hook,可以幫助邏輯鬆耦,這裡就演示下 hook 的撰寫
### useGetPagePos
我們有個需求:點擊畫面時取得最新的 x, y 座標,雖然是講單的功能,但要在多個組件下復用功能,vue2 的處理上叫比較麻煩了
* `vue2` 方案
* 寫 js util 在到各組件中調用,此時會失去生命週期的能力
* 使用 mixins 注入到組件中,此時會有重名汙染等問題
* ... 等接決方案都不能很優雅地進行處理
* `vue3` 方案
* 使用 hooks 封裝邏輯
* 使用 mixins 封裝,vue3 使用成員來綁定,不會有重名問題
* 使用 inject / provide
#### 實現
- hook
```typescript=
import { onMounted, onUnmounted, reactive, toRefs } from "vue";
function useGetPagePos() {
const data = reactive({
x: 0,
y: 0,
});
// 將私有方法控制在 hook 裡面
const onUpdatePosition = ({ pageX, pageY }: MouseEvent) => {
data.x = pageX;
data.y = pageY;
};
// 可以輕易取得生命週期來處理更多操作,如:廢棄事件
onMounted(() => document.addEventListener("click", onUpdatePosition));
onUnmounted(() => document.removeEventListener("click", onUpdatePosition));
// 將邏輯處理完後把 x, y 報露出去
return {
...toRefs(data),
};
}
export default useGetPagePos;
```
- 調用
```javascript=
setup() {
// 任何組件想重用邏輯只要使用 hook 取出即可
const pagePos = useGetPagePos()
return { ...pagePos }
}
```
## Component 組件
此節示範 vue3 的基礎組件撰寫指南(此節以簡單的 Dropdown 為例)
### 好的組件最主要具備哪些?
組件是用來對重覆處理的邏輯進行封裝,而這之中也有需要注意到的幾個地方
> 接下來的組件示範也會遵照此規範製作
* **語意化(可選)** 讓使用者清楚明白這組件是什麼,如何調用?
以 HTML 為例:
```html=
<!-- 👎 不清楚 -->
<div>按鈕<div>
<!-- 👍 清楚 -->
<button>按鈕</button>
```
以上兩者哪個更能清楚知道是按鈕呢?想必是 `button` 標籤對吧?,所以一個好的組件必須有好的語意標籤
至於為什麼是 `可選` 呢?上面提到組件是對邏輯的封裝,有些人喜歡組件的邏輯封裝得相當徹底,讓使用者傳入資料就能渲染出來,那我寫出來的組件可能會是這樣:
```html=
<Dropdown :list={list} />
```
將 list 傳入就把這個下拉菜單都渲染出來,不需要像 ul > li 那樣在把 dropdown-item 等 HTML 寫在畫面上再用 v-for 進行渲染
可以參考 (資料流) [Vuetify](https://vuetifyjs.com/en/components/selects/)、[Quasar](https://quasar.dev/vue-components/select) 與 (語意流) [Element](https://element.eleme.io/#/zh-CN/component/select)、[Ant Design](https://www.antdv.com/components/select/) 組件使用方式
* **清晰可見的文檔** 像組件庫都會提供良好的使用文檔參閱,沒文檔可在組件內申明清楚
```javascript=
// 以 vue2 為例
// 👎 邏輯多了難以知道有哪些 emit;props 又是什麼類型、是否必傳等...
export default {
props: ['name'],
// ...do something
methods: {
onClick() {
this.$emit('on-click')
}
}
}
// 以 vue3 為例
// 👍 參數、方法等清晰可見
export default {
props: {
name: {
type: String,
required: false,
default: ''
}
},
emit: ['on-click']
}
```
使用 ts 可以讓文檔更清晰
```typescript=
interface IOnClickPayload {
name: string
}
export default {
props: {
list: {
type: Array as PropType<string[]>,
required: false,
default: []
}
},
emit: {
'on-click': (payload: IOnClickPayload) => true
}
}
```
* **沒有過多的外部重複邏輯依賴** 這樣不僅不好用而且也沒達到組件封裝的意義
```javascript=
// 👎 過多的外部重複邏輯依賴
setup() {
const [list, auth, menu, info] = await Promise.all(
[service.getList(),
service.getAuth(),
service.getMenu(),
service.getInfo()])
return <MyComp list={list} auth={auth} menu={menu} info={info} />
}
// 👍 將重複邏輯封裝其中
setup() {
return <MyComp />
}
```
### 實作
#### 調用
* `dropdown` 頂層組件
* `dropdown-item` 下拉選項
```html=
<dropdown>
<button>下拉菜單</button>
<!-- 有空閒的小夥伴可以考慮優化成 dropdown-menu -->
<template #menu>
<dropdown-item>frank</dropdown-item>
<dropdown-item>超級帥</dropdown-item>
<dropdown-item>tia</dropdown-item>
<dropdown-item disabled>超漂亮</dropdown-item>
</template>
</dropdown>
```
#### dropdown
實現起來還是相對沒難度的,主要實現以下需求即可
* `default slot` 傳 dropdown 展示樣子,點擊可以開關菜單
* `menu slot` 傳 dropdown-item 元素
* `點擊外面關閉下拉`
- template
```html=
<div class="v-dropdown">
<div @click.stop="onToggleShow">
<slot></slot>
</div>
<div ref="menuRef" class="v-dropdown-menu" v-if="isShow">
<slot name="menu"></slot>
</div>
</div>
```
* script
```typescript=
export default defineComponent({
name: "Dropdown",
setup() {
// 點擊 default slot 開關菜單
const isShow = ref(false)
const onToggleShow = () => isShow.value = !isShow.value
// 是否點擊外面判斷
const menuRef = ref<null | HTMLElement>(null)
const onClickOutside = (ev: MouseEvent) => {
if (menuRef.value !== null) {
if (isShow && !menuRef.value.contains(ev.target as HTMLElement)) {
// 點擊外面就關閉
isShow.value = false
}
}
}
// 點擊外面事件綁定
onMounted(() => {
document.addEventListener('click', onClickOutside)
})
// 點擊外面事件銷毀
onUnmounted(() => {
document.removeEventListener('click', onClickOutside)
})
return {
isShow, onToggleShow, menuRef
}
}
})
```
我們看下 script 代碼,點擊外面處理看起來就是一個復用邏輯,聰明的小夥伴可以知道要做什麼了,沒錯!就是把它封裝成一個 hook 來調用,順便復盤一下 hook 設計邏輯
* useClickOutside.ts
```typescript=
// 把邏輯抽離到 hook 裡,傳遞 ref 來判斷是否點擊外面
const useClickOutside = (menuRef: Ref<null | HTMLElement>) => {
const isClickOutside = ref(false)
const onClickOutside = (ev: MouseEvent) => {
if (menuRef.value !== null) {
isClickOutside.value = !menuRef.value.contains(ev.target as HTMLElement)
}
}
onMounted(() => document.addEventListener('click', onClickOutside))
onUnmounted(() => document.removeEventListener('click', onClickOutside))
// 最後將是否點擊外面的值暴露出去
return { isClickOutside }
}
export default useClickOutside
```
#### script 改造
有了這個鉤子,我們就可以把原本的 script 代碼改造下
```typescript=
setup() {
const isShow = ref(false)
const onToggleShow = () => isShow.value = !isShow.value
const menuRef = ref<null | HTMLElement>(null)
// 引入鉤子,並用 watch 監聽是否點擊在外,達到了邏輯復用與抽離
const {isClickOutside} = useClickOutside(menuRef)
watch(isClickOutside, isOutside => (isShow.value && isOutside) && (isShow.value = false, isClickOutside.value = false))
return {
isShow, onToggleShow, menuRef
}
}
```
#### dropdown-item
實現起來相當簡單,只要接收一個 disabled props 再改變樣式即可
* template
```html=
<div class="dropdown-item"
:class="{'is-disabled': disabled}">
<slot></slot>
</div>
```
* script
```typescript=
export default defineComponent({
name: "DropdownItem",
props: {
disabled: {
type: Boolean,
required: false,
default: false
}
}
})
```
以上就完成了簡單的組件製作範例,不過本教學有埋了一個小 bug 進去,這部分就由小夥伴們自行解決囉!
## Vue3 的最佳開發方式
待更新
---
# Vue-router
現在 vue3 版的 vue-router 通稱為 `vue-router-next`
[github](https://github.com/vuejs/vue-router-next) | [官網](https://next.router.vuejs.org/introduction.html)
## 原理
稍微解釋 vue-router 的實現原理,雖然對使用本插件沒有關係,但多了解一些也沒壞處
這個 API 幫助我們可以在不刷新頁面的前提下動態改變瀏覽器地址欄中的URL地址,動態修改頁面上所顯示資源
**history.pushState(state, title, url) 添加一條歷史記錄,不刷新頁面參數**
* **state** 一個於指定網址相關的狀態對象,popstate事件觸發時,該對象會傳入回調函數中。如果不需要這個對象,此處可以填null
* **title** 新頁面的標題,但是所有瀏覽器目前都忽略這個值,因此這裡可以填null
* **url** 新的網址,必須與前頁面處在同一個域。瀏覽器的地址欄將顯示這個網址
[HTML5 History API](https://developer.mozilla.org/zh-CN/docs/Web/API/History_API)
### 簡單例子
```javascript=
const handleChange = (url, content) => {
// go to url
window.history.pushState(null, "hello there", url)
// new data
document.getElementById("app").innerHTML = `
<h1>${content}</h1>
`
}
document.getElementById("change").addEventListener("click", e => {
e.preventDefault()
handleChange("create.html", "create")
})
document.getElementById("home").addEventListener("click", e => {
e.preventDefault()
handleChange("/", "home")
})
```
vue-router 就是採用這種方式將組件渲染到畫面上,使得畫面不會重新加載資源而造成白屏現象,原理就分享到這
以下正文開始:
## 安裝
~~使用 `@next` 安裝,來保證版本是 4.0.0 以上,才能支援 vue3~~
```shell=
npm i vue-router
# OR
yarn add vue-router
```
## 配置
* `vue2` 使用 VueRouter 實例化創建
* `vue3` 使用 createRouter 創建
```javascript=
// vue2
const router = new VueRouter({
routes: []
})
new Vue({router}).$mount('#app')
// vue3
import { createRouter, createWebHistory } from 'vue-router'
const routerHistory = createWebHistory()
const router = createRouter({
history: routerHistory,
routes: []
})
const app = createApp(App)
app.use(router)
app.mount('#app')
```
## router-link、router-view
基本一致
## 使用
* `vue2` 用 this.$
* `vue3` 使用鉤子 use...
```javascript=
// vue2
this.$route
this.$router
// vue3
useRoute()
useRouter()
```
---
# Vuex
目前僅向上支援到可以使用,爾後正式版會有全新的使用方式,到時候會再更新
[vuex](https://next.vuex.vuejs.org/)
## 安裝
~~使用 `@next` 安裝,來保證版本是 4.0.0 以上,才能支援 vue3~~
```shell=
npm i vuex
# OR
yarn add vuex
```
## 配置
* `vue2` 使用 Vuex.Stroe 實例化創建
* `vue3` 使用 createStore 創建
```javascript=
// vue2
new Vuex.Store({})
Vue.use(Vuex)
// vue3
const store = createStore({})
const app = createApp(App)
app.use(store)
app.mount('#app')
```
## 申明
基本一致,此節講解 vue3 ts 寫法
```typescript=
interface UserProps {
isLogin: boolean;
name?: string;
id?: number;
}
export interface GlobalDataProps {
user: UserProps;
}
// 掛載類型才能正確識別類型
const store = createStore<GlobalDataProps>({
state: {
user: { isLogin: false }
},
mutations: {
login(state) {
state.user = { ...state.user, isLogin: true, name: 'frank' }
}
}
})
```
## 使用
* `vue2` 用 this.$
* `vue3` 使用鉤子 use...
除了取得 store 外,其他基本一致
```typescript=
// vue2
this.$store
// vue3
// 建議掛上類型,這樣 idea 類型補全表現較好
useStore<GlobalDataProps>()
```