Vue3 进阶
Vuex
前端的数据管理,专门定义一个全局变量。一些通用的数据,比如用户登录信息,以及一个跨层级的组件通信都可以通过这个全局变量很好地实现。使用 _store 这个全局变量存储数据,任何组件内部都可以通过 window._store 获取数据并且修改
但是 window._store 不是响应式的,无法自动更新页面。需要用 ref 和 reactive 去把数据包裹成响应式数据,并且提供统一的操作方法,这其实就是数据管理框架 Vuex 的雏形
vuex 简单使用
npm install vuex@nextmutation 内部的函数会把 state 作为参数,直接操作 state.count 就可以完成数据的修改
// src/store/index.js
import { createStore } from 'vuex'
const store = createStore({
state () {
return {
count: 666
}
},
mutations: {
add (state) {
state.count++
}
}
})
export default store在 src/main.js 导入 vuex
// src/main.js
import store from './store/index.js'
const app = createApp(App)
app.use(store).use(router).mount('#app')src/components/Count.vue
<template>
<div @click="add">
{{count}}
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'
let store = useStore()
let count = computed(()=>store.state.count)
function add(){
store.commit('add')
}
</script>手写迷你 Vuex
src/store/gvuex.js 使用一个 Store 类来管理数据,类的内部使用 _state 存储数据,使用 mutations 来存储数据修改的函数,注意这里的 state 已经使用 reactive 包裹成响应式数据了
自定义实现 Gvuex 功能:
import { inject, reactive } from 'vue'
const STORE_KEY = '__store__'
function useStore() {
return inject(STORE_KEY)
}
function createStore(options) {
return new Store(options)
}
class Store {
constructor(options) {
this._state = reactive({
data: options.state()
})
this._mutations = options.mutations
}
get state() {
return this._state.data
}
commit = (type, payload) => {
const entry = this._mutations[type]
entry && entry(this.state, payload)
}
// main.js 入口处 app.use(store) 的时候,会执行这个函数
install(app) {
app.provide(STORE_KEY, this)
}
}
export { createStore, useStore }src/store/index.js 使用 createStore 创建了一个 store 实例,并且实例内部使用 state 定义了 count 变量和修改 count 值的 add 函数
import { createStore } from './gvuex'
const store = createStore({
state () {
return {
count: 666
}
},
mutations: {
add (state) {
state.count++
}
}
})
export default store在项目入口文件 src/main.js 中导入 src/store/index.js 使用 app.use(store) 注册
src/components/Count.vue
import {useStore} from '../store/gvuex'
let store =useStore()
let count = computed(()=>store.state.count)
function add(){
store.commit('add')
}路由
jQuery 时代大部分 Web 项目,前端是不能控制路由,需要依赖后端项目的路由系统。通常前端项目也会部署在后端项目的模板里

jQuery 那个时代的前端工程师,都要学会在后端的模板,比如 JSP,Smatry 等里面写一些代码。但是在这个时代,前端工程师并不需要了解路由的概念。对于每次的页面跳转,都由后端开发人员来负责重新渲染模板。
前端依赖后端,并且前端不需要负责路由的这种开发方式,有很多的优点,比如开发速度会很快、后端也可以承担部分前端任务等,所以到现在还有很多公司的内部管理系统是这样的架构。当然这种开发方式也有很多缺点,比如前后端项目无法分离、页面跳转由于需要重新刷新整个页面、等待时间较长等等,所以也会让交互体验下降
为了提高页面的交互体验,很多前端工程师做了不同的尝试。在这个过程中,前端的开发模式发生了变化,项目的结构也发生了变化

用户访问路由后无论是什么 URL 地址,都直接渲染一个前端的入口文件 index.html,然后就会在 index.html 文件中加载 JS 和 CSS。之后 JavaScript 获取当前的页面地址,以及当前路由匹配的组件,再去动态渲染当前页面即可。用户在页面上进行点击操作时,也不需要刷新页面,而是直接通过 JS 重新计算出匹配的路由渲染即可
通过 JavaScript 动态控制数据去提高用户体验的方式并不新奇,Ajax 让数据的获取不需要刷新页面,SPA 应用让路由跳转也不需要刷新页面
前端路由的实现原理
通过 URL 区分路由的机制上,有两种实现方式
- hash 模式通过 URL 中
#后面的内容做区分,称之为 hash-router - history 模式在这种方式下,路由看起来和正常的 URL 完全一致
在 vue-router 中对应两个函数,分别是 createWebHashHistory 和 createWebHistory

hash 模式
单页应用在页面交互、页面跳转上都是无刷新的,这极大地提高了用户访问网页的体验。为了实现单页应用,前端路由的需求也变得重要了起来。
类似于服务端路由,前端路由实现起来其实也很简单,就是匹配不同的 URL 路径进行解析,然后动态地渲染出区域 HTML 内容。但是这样存在一个问题,就是 URL 每次变化的时候,都会造成页面的刷新。解决这一问题的思路便是在改变 URL 的情况下,保证页面的不刷新
hash 值的变化并不会导致浏览器页面的刷新,只是会触发 hashchange 事件
window.addEventListener('hashchange',fn)history 模式
2014 年之后 HTML5 标准发布,浏览器多了两个 API:pushState 和 replaceState。通过这两个 API 可以改变 URL 地址,并且浏览器不会向后端发送请求,就能用另外一种方式实现前端路由
window.addEventListener('popstate', fn)手写迷你 vue-router
grouter
// src/router/grouter/index.js
import {ref,inject} from 'vue'
import RouterLink from './RouterLink.vue'
import RouterView from './RouterView.vue'
const ROUTER_KEY = '__router__'
function createRouter(options){
return new Router(options)
}
function useRouter(){
return inject(ROUTER_KEY)
}
function createWebHashHistory(){
function bindEvents(fn){
window.addEventListener('hashchange',fn)
}
return { bindEvents,url:window.location.hash.slice(1) || '/' }
}
class Router{
constructor(options){
this.history = options.history
this.routes = options.routes
this.current = ref(this.history.url)
this.history.bindEvents(()=>{
this.current.value = window.location.hash.slice(1)
})
}
install(app){
app.provide(ROUTER_KEY,this)
app.component("router-link",RouterLink)
app.component("router-view",RouterView)
}
}
export {createRouter,createWebHashHistory,useRouter}src/router/index.js 中使用 createWebHashHistory 作为 history 参数,使用 routes 作为页面的参数传递给 createRouter 函数
import {createRouter,createWebHashHistory,} from './grouter/index'
const router = createRouter({
history: createWebHashHistory(),
routes
})RouterView 和 RouterILink
需要注册两个内置组件 router-view 和 router-link。在 createRouter 创建的 Router 实例上,current 返回当前的路由地址,并且使用 ref 包裹成响应式的数据。router-view 组件的功能,就是 current 发生变化的时候,去匹配 current 地址对应的组件,然后动态渲染到 router-view 就可以
src/router/grouter/RouterView.vue
<template>
<component :is="comp"></component>
</template>
<script setup>
import {computed } from 'vue'
import { useRouter } from '../grouter/index'
let router = useRouter()
const comp = computed(()=>{
const route = router.routes.find(
(route) => route.path === router.current.value
)
return route?route.component : null
})
</script>src/router/grouter/RouterILink.vue
<template>
<a :href="'#'+props.to">
<slot />
</a>
</template>
<script setup>
import {defineProps} from 'vue'
let props = defineProps({
to:{
type:String,
required:true
}
})
</script>这里实现了 hash 模式的迷你 vue-router ,history 模式又该如何实现。实际上 vue-router 还需要处理很多额外的任务,比如路由懒加载、路由的正则匹配等等
JSX 开发场景
Vue 中的 JSX 在组件库、路由库这类开发场景中,也发挥着重要的作用
h 函数
Vue3 的项目开发中 template 是 Vue3 默认的写法。虽然 template 长得很像 HTML,但 Vue 会把 template 解析为 render 函数,之后组件运行的时候通过 render 函数去返回虚拟 DOM,可以在 Vue Devtools 中看到组件编译之后的结果

除了 template 之外某些场景下可以直接写 render 函数来实现组件。需要通过一个值的范围在数字 1 到 6 之间的变量,去渲染标题组件 h1~h6,并根据传递的 props 去渲染标签名
<h1 v-if="num==1">{{title}}</h1>
<h2 v-if="num==2">{{title}}</h2>
<h3 v-if="num==3">{{title}}</h3>
<h4 v-if="num==4">{{title}}</h4>
<h5 v-if="num==5">{{title}}</h5>
<h6 v-if="num==6">{{title}}</h6>由于 render 函数可以直接返回虚拟 DOM,因而就不再需要 template。在 src/components/Heading.jsx 目录
import { defineComponent, h } from 'vue'
export default defineComponent({
props: {
level: {
type: Number,
required: true
}
},
setup(props, { slots }) {
return () => h(
'h' + props.level, // 标签名
{}, // prop 或 attribute
slots.default() // 子节点
)
}
})在 src/About.vue 中使用 Heading.jsx
<template>
<Heading :level="3">hello geekbang</Heading>
</template>
<script setup>
import Heading from './components/Head.jsx'
</script>手写的 h 函数,可以处理动态性更高的场景。但是复杂的场景,h 函数写起来就显得非常繁琐,需要自己把所有的属性都转变成对象。并且组件嵌套的时候,对象也会变得非常复杂。可以通过 JSX 更方便的去写 h 函数
JSX
JSX 来源自 React 框架,本质就是下面代码的语法糖,h 函数内部也是调用 createVnode 来返回虚拟 DOM
const element = <h1 id="app">Hello, Geekbang!</h1>
// 等价于
const element = createVnode('h1',{id:"app"}, 'hello Geekbakg')在从 JSX 到 createVNode 函数的转化过程中,需要安装一个 JSX 插件
npm install @vitejs/plugin-vue-jsx -D在 vite.config.js 文件去修改 vite 配置
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx';
export default defineConfig({
plugins: [vue(),vueJsx()]
})进入 src/componentns/Heading.jsx 中修改 setup 的返回函数,使用变量 tag 计算出标签类型,直接使用渲染,使用一个大括号把默认插槽包起来
setup(props, { slots }) {
const tag = 'h'+props.level
return () => <tag>{slots.default()}</tag>
}主要针对之前的清单应用讲解一下 src/components/Todo.jsx
- 使用 defineComponent 的方式来定义组件
- 在 setup 返回的 JSX 中,使用 vModel 取代 v-model,并且使用单个大括号包裹的形式传入变量
title.value - 然后使用 onClick 取代 @click。循环渲染清单的时候,使用
.map映射取代 v-for,使用三元表达式取代v-if
import { defineComponent, ref } from 'vue'
export default defineComponent({
setup(props) {
let title = ref('')
let todos = ref([
{ title: "学习 Vue 3", done: true },
{ title: "睡觉", done: false }
]);
function addTodo(){
todos.value.push({
title:title.value
})
title.value = ''
}
return () => <div>
<input type="text" vModel={title.value} />
<button onClick={addTodo}>click</button>
<ul>
{
todos.value.length ? todos.value.map(todo=>{
return <li>{todo.title}</li>
}): <li>no data</li>
}
</ul>
</div>
}
})使用 JSX 的本质还是在写 JavaScript
JSX 和 Template 对比
template 的语法是固定的,只有 v-if、v-for 等等语法。也就是说,template 遇见条件渲染就是要固定的选择用 v-if。我们按照这种固定格式的语法书写,这样 Vue 在编译层面就可以很方便地去做静态标记的优化。
而 JSX 只是 h 函数的一个语法糖,本质就是 JavaScript,想实现条件渲染可以用 if else,也可以用三元表达式,还可以用任意合法的 JavaScript 语法。也就是说,JSX 可以支持更动态的需求。而 template 则因为语法限制原因,不能够像 JSX 那样可以支持更动态的需求。这是 JSX 相比于 template 的一个优势
JSX 相比于 template 还有一个优势,是可以在一个文件内返回多个组件
export const Button = (props,{slots})=><button {...props}>slots.default()</button>
export const Input = (props)=><input {...props} />
export const Timeline = (props)=>{
// ...
}相比于自己去写 h 函数,在 template 解析的结果中,有以下几个性能优化的方面。
- 静态的标签和属性会放在
_hoisted变量中,并且放在 render 函数之外。这样,重复执行 render 的时候,代码里的 h1 这个纯静态的标签,就不需要进行额外地计算,并且静态标签在虚拟 DOM 计算的时候,会直接越过 Diff 过程。 - 然后是 @click 函数增加了一个 cache 缓存层,这样实现出来的效果也是和静态提升类似,尽可能高效地利用缓存。最后是,由于在下面代码中的属性里,那些带冒号的属性是动态属性,因而存在使用一个数字去标记标签的动态情况。
- 比如在 p 标签上,使用 8 这个数字标记当前标签时,只有 props 是动态的。而在虚拟 DOM 计算 Diff 的过程中,可以忽略掉 class 和文本的计算,这也是 Vue 3 的虚拟 DOM 能够比 Vue 2 快的一个重要原因
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("h1", null, "技术摸鱼", -1 /* HOISTED */)
const _hoisted_3 = ["id"]
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createElementVNode("div", {
onClick: _cache[0] || (_cache[0] = ()=>_ctx.console.log(_ctx.xx)),
name: "hello"
}, _toDisplayString(_ctx.name), 1 /* TEXT */),
_hoisted_2,
_createElementVNode("p", {
id: _ctx.name,
class: "app"
}, "极客时间", 8 /* PROPS */, _hoisted_3)
]))
}
// Check the console for the AST在 template 和 JSX 这两者的选择问题上,只是选择框架时角度不同而已。实现业务需求的时候,也是优先使用 template,动态性要求较高的组件使用 JSX 实现,尽可能地利用 Vue 本身的性能优化