Vuex在SSR中的配置

基础Store配置

// src/store/index.js
import { createStore } from 'vuex'
import posts from './modules/posts'
import users from './modules/users'
import auth from './modules/auth'

export function createStore() {
  return createStore({
    modules: {
      posts,
      users,
      auth
    },
    
    state: {
      loading: false,
      error: null
    },
    
    mutations: {
      SET_LOADING(state, loading) {
        state.loading = loading
      },
      
      SET_ERROR(state, error) {
        state.error = error
      },
      
      CLEAR_ERROR(state) {
        state.error = null
      }
    },
    
    actions: {
      setLoading({ commit }, loading) {
        commit('SET_LOADING', loading)
      },
      
      setError({ commit }, error) {
        commit('SET_ERROR', error)
      },
      
      clearError({ commit }) {
        commit('CLEAR_ERROR')
      }
    }
  })
}

Posts模块

// src/store/modules/posts.js
import api from '@/api'

const state = {
  posts: [],
  currentPost: null,
  pagination: {
    page: 1,
    limit: 10,
    total: 0
  }
}

const mutations = {
  SET_POSTS(state, posts) {
    state.posts = posts
  },
  
  SET_CURRENT_POST(state, post) {
    state.currentPost = post
  },
  
  ADD_POST(state, post) {
    state.posts.unshift(post)
  },
  
  UPDATE_POST(state, updatedPost) {
    const index = state.posts.findIndex(p => p.id === updatedPost.id)
    if (index !== -1) {
      state.posts.splice(index, 1, updatedPost)
    }
  },
  
  DELETE_POST(state, postId) {
    state.posts = state.posts.filter(p => p.id !== postId)
  },
  
  SET_PAGINATION(state, pagination) {
    state.pagination = { ...state.pagination, ...pagination }
  }
}

const actions = {
  async fetchPosts({ commit, state }, { page = 1, limit = 10 } = {}) {
    try {
      commit('SET_LOADING', true, { root: true })
      
      const response = await api.get('/posts', {
        params: { page, limit }
      })
      
      commit('SET_POSTS', response.data.posts)
      commit('SET_PAGINATION', {
        page: response.data.page,
        limit: response.data.limit,
        total: response.data.total
      })
      
      return response.data
    } catch (error) {
      commit('SET_ERROR', error.message, { root: true })
      throw error
    } finally {
      commit('SET_LOADING', false, { root: true })
    }
  },
  
  async fetchPost({ commit }, postId) {
    try {
      commit('SET_LOADING', true, { root: true })
      
      const response = await api.get(`/posts/${postId}`)
      commit('SET_CURRENT_POST', response.data)
      
      return response.data
    } catch (error) {
      commit('SET_ERROR', error.message, { root: true })
      throw error
    } finally {
      commit('SET_LOADING', false, { root: true })
    }
  },
  
  async createPost({ commit }, postData) {
    try {
      const response = await api.post('/posts', postData)
      commit('ADD_POST', response.data)
      return response.data
    } catch (error) {
      commit('SET_ERROR', error.message, { root: true })
      throw error
    }
  }
}

const getters = {
  getPostById: (state) => (id) => {
    return state.posts.find(post => post.id === parseInt(id))
  },
  
  getPostsByCategory: (state) => (category) => {
    return state.posts.filter(post => post.category === category)
  },
  
  totalPages: (state) => {
    return Math.ceil(state.pagination.total / state.pagination.limit)
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
}

Pinia在SSR中的配置

Pinia Store设置

// src/store/index.js
import { createPinia } from 'pinia'

export function createStore() {
  const pinia = createPinia()
  
  // SSR状态序列化支持
  if (import.meta.env.SSR) {
    pinia.use(({ store }) => {
      // 服务端状态序列化
      store.$serialize = () => {
        return JSON.stringify(store.$state)
      }
      
      // 客户端状态恢复
      store.$hydrate = (state) => {
        store.$patch(JSON.parse(state))
      }
    })
  }
  
  return pinia
}

Pinia Store定义

// src/store/posts.js
import { defineStore } from 'pinia'
import api from '@/api'

export const usePostsStore = defineStore('posts', {
  state: () => ({
    posts: [],
    currentPost: null,
    loading: false,
    error: null,
    pagination: {
      page: 1,
      limit: 10,
      total: 0
    }
  }),
  
  getters: {
    getPostById: (state) => {
      return (id) => state.posts.find(post => post.id === parseInt(id))
    },
    
    getPostsByCategory: (state) => {
      return (category) => state.posts.filter(post => post.category === category)
    },
    
    totalPages: (state) => {
      return Math.ceil(state.pagination.total / state.pagination.limit)
    }
  },
  
  actions: {
    async fetchPosts({ page = 1, limit = 10 } = {}) {
      this.loading = true
      this.error = null
      
      try {
        const response = await api.get('/posts', {
          params: { page, limit }
        })
        
        this.posts = response.data.posts
        this.pagination = {
          page: response.data.page,
          limit: response.data.limit,
          total: response.data.total
        }
        
        return response.data
      } catch (error) {
        this.error = error.message
        throw error
      } finally {
        this.loading = false
      }
    },
    
    async fetchPost(postId) {
      this.loading = true
      this.error = null
      
      try {
        const response = await api.get(`/posts/${postId}`)
        this.currentPost = response.data
        return response.data
      } catch (error) {
        this.error = error.message
        throw error
      } finally {
        this.loading = false
      }
    },
    
    async createPost(postData) {
      try {
        const response = await api.post('/posts', postData)
        this.posts.unshift(response.data)
        return response.data
      } catch (error) {
        this.error = error.message
        throw error
      }
    }
  }
})

数据预取策略

组件级数据预取

<!-- src/pages/PostDetail.vue -->
<template>
  <div class="post-detail">
    <div v-if="loading">加载中...</div>
    <div v-else-if="error" class="error">
      {{ error }}
    </div>
    <article v-else-if="post">
      <header>
        <h1>{{ post.title }}</h1>
        <div class="meta">
          <span>作者: {{ post.author }}</span>
          <span>发布时间: {{ formatDate(post.createdAt) }}</span>
          <span>分类: {{ post.category }}</span>
        </div>
      </header>
      
      <div class="content" v-html="post.content"></div>
      
      <footer>
        <div class="tags">
          <span v-for="tag in post.tags" :key="tag" class="tag">
            {{ tag }}
          </span>
        </div>
      </footer>
    </article>
  </div>
</template>

<script>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { usePostsStore } from '@/store/posts'

export default {
  name: 'PostDetail',
  
  // SSR数据预取
  async asyncData({ store, route }) {
    const postId = route.params.id
    
    // Vuex版本
    // await store.dispatch('posts/fetchPost', postId)
    
    // Pinia版本
    const postsStore = usePostsStore(store)
    await postsStore.fetchPost(postId)
  },
  
  setup() {
    const route = useRoute()
    const postsStore = usePostsStore()
    
    const post = computed(() => postsStore.currentPost)
    const loading = computed(() => postsStore.loading)
    const error = computed(() => postsStore.error)
    
    const formatDate = (date) => {
      return new Date(date).toLocaleDateString('zh-CN')
    }
    
    // 客户端数据获取
    onMounted(async () => {
      const postId = route.params.id
      if (!post.value || post.value.id !== parseInt(postId)) {
        await postsStore.fetchPost(postId)
      }
    })
    
    return {
      post,
      loading,
      error,
      formatDate
    }
  }
}
</script>

路由级数据预取

// src/router/index.js
import { usePostsStore } from '@/store/posts'

router.beforeResolve(async (to, from, next) => {
  const matched = router.resolve(to).matched
  const prevMatched = router.resolve(from).matched
  
  const activated = matched.filter((c, i) => {
    return prevMatched[i] !== c
  })
  
  if (!activated.length) {
    return next()
  }
  
  try {
    // 并行执行所有数据预取
    await Promise.all(activated.map(async (component) => {
      if (component.components.default.asyncData) {
        // 获取store实例
        const store = router.app.config.globalProperties.$store
        
        return component.components.default.asyncData({
          store,
          route: to,
          router
        })
      }
    }))
    
    next()
  } catch (error) {
    console.error('数据预取失败:', error)
    next(error)
  }
})

状态序列化与恢复

服务端状态序列化

// src/entry-server.js
export async function render(url, context = {}) {
  const { app, router, store } = createApp()
  
  await router.push(url)
  await router.isReady()
  
  const matchedComponents = router.currentRoute.value.matched
  
  // 执行数据预取
  await Promise.all(
    matchedComponents.map(component => {
      if (component.components.default.asyncData) {
        return component.components.default.asyncData({
          store,
          route: router.currentRoute.value
        })
      }
    })
  )
  
  // 序列化状态
  if (store.state) {
    // Vuex
    context.state = store.state
  } else {
    // Pinia
    context.state = {}
    store._s.forEach((storeInstance, key) => {
      context.state[key] = storeInstance.$state
    })
  }
  
  return app
}

客户端状态恢复

// src/entry-client.js
import { createApp } from './app'

const { app, router, store } = createApp()

// 恢复服务端状态
if (window.__INITIAL_STATE__) {
  if (store.replaceState) {
    // Vuex
    store.replaceState(window.__INITIAL_STATE__)
  } else {
    // Pinia
    Object.keys(window.__INITIAL_STATE__).forEach(key => {
      const storeInstance = store._s.get(key)
      if (storeInstance) {
        storeInstance.$patch(window.__INITIAL_STATE__[key])
      }
    })
  }
}

router.isReady().then(() => {
  app.mount('#app', true)
})

API客户端配置

通用API客户端

// src/api/index.js
import axios from 'axios'

// 创建axios实例
const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api',
  timeout: 10000
})

// 请求拦截器
api.interceptors.request.use(
  (config) => {
    // 添加认证token
    const token = getToken()
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// 响应拦截器
api.interceptors.response.use(
  (response) => {
    return response
  },
  (error) => {
    if (error.response?.status === 401) {
      // 处理认证失败
      clearToken()
      if (typeof window !== 'undefined') {
        window.location.href = '/login'
      }
    }
    
    return Promise.reject(error)
  }
)

// 获取token的通用方法
function getToken() {
  if (typeof window !== 'undefined') {
    // 客户端从localStorage获取
    return localStorage.getItem('token')
  } else {
    // 服务端从请求头获取
    // 这里需要通过context传递
    return null
  }
}

function clearToken() {
  if (typeof window !== 'undefined') {
    localStorage.removeItem('token')
  }
}

export default api

SSR中的API调用

// src/api/ssr.js
import axios from 'axios'

// 为SSR创建专门的API客户端
export function createApiClient(context = {}) {
  const api = axios.create({
    baseURL: process.env.API_URL || 'http://localhost:3001/api',
    timeout: 10000
  })
  
  // 在服务端添加请求头
  if (context.req) {
    const { req } = context
    
    // 转发认证头
    if (req.headers.authorization) {
      api.defaults.headers.Authorization = req.headers.authorization
    }
    
    // 转发cookie
    if (req.headers.cookie) {
      api.defaults.headers.Cookie = req.headers.cookie
    }
    
    // 转发用户IP
    if (req.headers['x-forwarded-for'] || req.connection.remoteAddress) {
      api.defaults.headers['X-Forwarded-For'] = 
        req.headers['x-forwarded-for'] || req.connection.remoteAddress
    }
  }
  
  return api
}

错误处理

全局错误处理

// src/store/modules/error.js
const state = {
  errors: [],
  globalError: null
}

const mutations = {
  ADD_ERROR(state, error) {
    state.errors.push({
      id: Date.now(),
      message: error.message,
      type: error.type || 'error',
      timestamp: new Date()
    })
  },
  
  REMOVE_ERROR(state, errorId) {
    state.errors = state.errors.filter(error => error.id !== errorId)
  },
  
  SET_GLOBAL_ERROR(state, error) {
    state.globalError = error
  },
  
  CLEAR_ERRORS(state) {
    state.errors = []
    state.globalError = null
  }
}

const actions = {
  addError({ commit }, error) {
    commit('ADD_ERROR', error)
    
    // 自动清除错误
    setTimeout(() => {
      commit('REMOVE_ERROR', error.id)
    }, 5000)
  },
  
  setGlobalError({ commit }, error) {
    commit('SET_GLOBAL_ERROR', error)
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

下一步

在下一章节中,我们将学习如何配置服务端渲染的Express服务器。