{T}

Vue3 进阶

Vuex

前端的数据管理,专门定义一个全局变量。一些通用的数据,比如用户登录信息,以及一个跨层级的组件通信都可以通过这个全局变量很好地实现。使用 _store 这个全局变量存储数据,任何组件内部都可以通过 window._store 获取数据并且修改

但是 window._store 不是响应式的,无法自动更新页面。需要用 ref 和 reactive 去把数据包裹成响应式数据,并且提供统一的操作方法,这其实就是数据管理框架 Vuex 的雏形

vuex 简单使用

bash
npm install vuex@next

mutation 内部的函数会把 state 作为参数,直接操作 state.count 就可以完成数据的修改

javascript
// 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

javascript
// src/main.js
import store from './store/index.js'

const app = createApp(App)
app.use(store).use(router).mount('#app')

src/components/Count.vue

Vue SFC
<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 功能:

javascript
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 函数

javascript
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

javascript
import {useStore} from '../store/gvuex'
let store =useStore()
let count = computed(()=>store.state.count)

function add(){
  store.commit('add')
}

路由

jQuery 时代大部分 Web 项目,前端是不能控制路由,需要依赖后端项目的路由系统。通常前端项目也会部署在后端项目的模板里

image-20240426170336468

jQuery 那个时代的前端工程师,都要学会在后端的模板,比如 JSP,Smatry 等里面写一些代码。但是在这个时代,前端工程师并不需要了解路由的概念。对于每次的页面跳转,都由后端开发人员来负责重新渲染模板。

前端依赖后端,并且前端不需要负责路由的这种开发方式,有很多的优点,比如开发速度会很快、后端也可以承担部分前端任务等,所以到现在还有很多公司的内部管理系统是这样的架构。当然这种开发方式也有很多缺点,比如前后端项目无法分离、页面跳转由于需要重新刷新整个页面、等待时间较长等等,所以也会让交互体验下降

为了提高页面的交互体验,很多前端工程师做了不同的尝试。在这个过程中,前端的开发模式发生了变化,项目的结构也发生了变化

image-20240426170405895

用户访问路由后无论是什么 URL 地址,都直接渲染一个前端的入口文件 index.html,然后就会在 index.html 文件中加载 JS 和 CSS。之后 JavaScript 获取当前的页面地址,以及当前路由匹配的组件,再去动态渲染当前页面即可。用户在页面上进行点击操作时,也不需要刷新页面,而是直接通过 JS 重新计算出匹配的路由渲染即可

通过 JavaScript 动态控制数据去提高用户体验的方式并不新奇,Ajax 让数据的获取不需要刷新页面,SPA 应用让路由跳转也不需要刷新页面

前端路由的实现原理

通过 URL 区分路由的机制上,有两种实现方式

  • hash 模式通过 URL 中 # 后面的内容做区分,称之为 hash-router
  • history 模式在这种方式下,路由看起来和正常的 URL 完全一致

在 vue-router 中对应两个函数,分别是 createWebHashHistory 和 createWebHistory

image-20240426170537979

hash 模式

单页应用在页面交互、页面跳转上都是无刷新的,这极大地提高了用户访问网页的体验。为了实现单页应用,前端路由的需求也变得重要了起来。

类似于服务端路由,前端路由实现起来其实也很简单,就是匹配不同的 URL 路径进行解析,然后动态地渲染出区域 HTML 内容。但是这样存在一个问题,就是 URL 每次变化的时候,都会造成页面的刷新。解决这一问题的思路便是在改变 URL 的情况下,保证页面的不刷新

hash 值的变化并不会导致浏览器页面的刷新,只是会触发 hashchange 事件

javascript
window.addEventListener('hashchange',fn)

history 模式

2014 年之后 HTML5 标准发布,浏览器多了两个 API:pushState 和 replaceState。通过这两个 API 可以改变 URL 地址,并且浏览器不会向后端发送请求,就能用另外一种方式实现前端路由

javascript
window.addEventListener('popstate', fn)

手写迷你 vue-router

grouter

javascript
// 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 函数

javascript
import {createRouter,createWebHashHistory,} from './grouter/index'

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

需要注册两个内置组件 router-view 和 router-link。在 createRouter 创建的 Router 实例上,current 返回当前的路由地址,并且使用 ref 包裹成响应式的数据。router-view 组件的功能,就是 current 发生变化的时候,去匹配 current 地址对应的组件,然后动态渲染到 router-view 就可以

src/router/grouter/RouterView.vue

Vue SFC
<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

Vue SFC
<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 中看到组件编译之后的结果

image-20240426171619318

除了 template 之外某些场景下可以直接写 render 函数来实现组件。需要通过一个值的范围在数字 1 到 6 之间的变量,去渲染标题组件 h1~h6,并根据传递的 props 去渲染标签名

html
<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 目录

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

Vue SFC
<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

jsx
const element = <h1 id="app">Hello, Geekbang!</h1>

// 等价于
const element = createVnode('h1',{id:"app"}, 'hello Geekbakg')

在从 JSX 到 createVNode 函数的转化过程中,需要安装一个 JSX 插件

bash
npm install @vitejs/plugin-vue-jsx -D

vite.config.js 文件去修改 vite 配置

javascript
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 计算出标签类型,直接使用渲染,使用一个大括号把默认插槽包起来

jsx
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
jsx
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 还有一个优势,是可以在一个文件内返回多个组件

jsx
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 快的一个重要原因
jsx
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 本身的性能优化