--- ###### tags: `vue` --- {%hackmd S1A4SV2pL %} ![](https://i.imgur.com/NJXDqad.png) 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 ![](https://v3.vuejs.org/images/v-bind-instead-of-sync.png) - `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 ![](https://v3.vuejs.org/images/components_provide.png) :::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>() ```