PHP项目集成vue3和vite

329 阅读7分钟

背景

早期基于yaf搭建的php后台管理系统,属于SSR,前端采用传统的jquery + bootstrap架构。 目前要在这个基础上需要开发新功能。考虑到后期可能重构和维护性。

  • 希望把SSR架构调整为前后端分离
  • UI希望使用更丰富的elementPlus
  • 希望代码编写 基于数据驱动,不在操作dom如vue3
  • 希望代码可以复用和兼容浏览器,如引入脚手架webpack vite等
  • 数据获取从php输出改成resful风格访问

方案1: php + vue3多页面应用

直接在对应的php页面上写vue3的多页面应用

分析

优点:

  1. 粗暴直接,直接引入cdn即可使用
  2. 调试快,不用搭脚手架,不用执行命令构建,刷新页面即可
  3. 使用cdn多次渲染,也不用重复再加载原来资源

缺点:

  1. 复用的代码只能过用js引入,容易导致全局污染
  2. 当页面多了,很难维护,无法做到组件化开发
  3. 编写html的标签必须有闭合标签,不能简写,如 <el-table-column label="姓名" align="center" prop="name" /> 将不会有意想不到的效果。
  4. 没有路由,后期如果要整体切换到spa应用调整成本也大

实战

1.项目结构

以yaf结构为列:

image.png

  • application/controllers 转发控制器
  • application/views 编写view视图代码
  • public/js/plguin 存放自第三方js库

2.controller

一般php使用mvc架构,controller 我们只需要做转发即可 如下:

application/controllers/test.php

<?php
/**
 * 测试
 */

use Model\AclModel;
use Model\AreaModel;
use Model\MenuModel;
use Mvc\AbstractController;

class ResourceController extends AbstractController
{ 


    /**
     * 测试vue响应式
     * @throws Exception
     */
    public function testvue3Action()
    { 
    
    } 

    /**
     * 测试 增删改查
     * @throws Exception
     */
    public function listAction()
    { 
        
    }

}

一般一个action对应一个phtml页面

image.png

3. vue3和elementui库添加

public/js/plugin

image.png

4. view视图

直接引入vue和编写vue

application/views/test/testvue3.phtml (做vue3测试响应式)

 <!-- vue3 JS & CSS files -->
 <script type="text/javascript" src="<?= $this->helper->basePath('/js/plugin/vue3/vue.global.min.js'); ?>"></script>  

 <div id="app">  
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button> 
 </div>

 <script type="module"> 
     const {
         createApp,
         ref
     } = Vue
     const app = createApp({
         setup() {
            const count = ref(0);
                const increment = () => {
                count.value++;
                }; 
                return {
                    count,
                    increment
                };
         }
     }) 
     app.mount('#app')
 </script>

 <style scoped> 
 </style>

application/views/test/list.phtml (集成vue3和elementuiPlus 常规的增删改,包括分页)

 <!-- vue3 JS & CSS files -->
 <script type="text/javascript" src="<?= $this->helper->basePath('/js/plugin/vue3/vue.global.min.js'); ?>"></script>
 <link href="<?= $this->helper->basePath('/js/plugin/elementplus/index.min.css'); ?>" rel="stylesheet" type="text/css">
 <script type="text/javascript" src="<?= $this->helper->basePath('/js/plugin/elementplus/index.full.min.js'); ?>"></script>
 <script type="text/javascript" src="<?= $this->helper->basePath('/js/plugin/elementplus/zh-cn.min.js'); ?>"></script> 
 <script type="text/javascript" src="<?= $this->helper->basePath('/js/plugin/axios/axios.min.js'); ?>"></script>
 <script type="text/javascript" src="<?= $this->helper->basePath('/js/plugin/moment/moment.min.js'); ?>"></script> 
 <script type="text/javascript" src="<?= $this->helper->basePath('/js/plugin/vue3/hooks/list.js'); ?>"></script>
 <script type="text/javascript" src="<?= $this->helper->basePath('/js/plugin/functional/forecast.js'); ?>"></script>

 <div id="app"> 
             <el-form ref="queryRef" :model="queryForm" inline label-width="100px">
                 <el-row>
                     <el-col :xl="25" :lg="6" :md="8" :sm="12">
                         <el-form-item label="名称" prop="name">
                            <el-input v-model="queryForm.name" />
                         </el-form-item>
                     </el-col> 
                     <el-col :xl="25" :lg="6" :md="8" :sm="12">
                         <div class="searchOpreaBox">
                             <el-button type="primary" @click="handleQuery">搜索</el-button>
                             <el-button @click="resetQuery">重置</el-button>
                         </div>
                     </el-col>
                 </el-row>
                 <el-row :gutter="10" class="mb8">
                     <el-col :span="1.5">
                         <el-button type="primary" @click="handleAdd">新增</el-button>
                     </el-col> 
                     <el-col :span="1.5">
                         <el-button type="danger" plain :disabled="!multiple" @click="handleDelete">删除</el-button>
                     </el-col>
                 </el-row>
             </el-form> 
             <el-row class="">
                 <el-col>
                     <el-table :data="tableData" v-loading="loading" @selection-change="handleSelectionChange">
                         <el-table-column type="selection" width="50" align="center"> </el-table-column>
                         <el-table-column label="姓名" align="center" prop="name"></el-table-column>
                         <el-table-column label="年龄" align="center" prop="age"></el-table-column>
                     </el-table>
                 </el-col>
             </el-row>
         <el-pagination background layout="total,sizes,->,prev, pager, next" :current-page="page.currentPage" :page-size="page.pageSize" :page-sizes="page.pageSizes" :pager-count="page.pagerCount" :total="page.total" @size-change="handleSizeChange" @current-change="handleCurrentChange"></el-pagination>
     <el-dialog title="新增/修改" v-model="open" width="500px" :close-on-click-modal="false">
         <el-form v-loading="postLoading" ref="postRef" :model="postForm" :rules="postRules" label-width="80px">
             <el-row>
                 <el-col>
                     <el-form-item label="姓名" prop="name">
                         <el-input v-model="postForm.name" />
                     </el-form-item>
                 </el-col> 
                 <el-col>
                     <el-form-item label="年龄" prop="range"> 
                            <el-input v-model="postForm.age" />
                     </el-form-item>
                 </el-col> 
             </el-row>
         </el-form>
         <template #footer>
             <div class="dialog-footer">
                 <el-button type="primary" @click="submitForm">提交</el-button>
                 <el-button @click="cancelForm">取 消</el-button>
             </div>
         </template>
     </el-dialog>
 </div>

 <script type="module"> 

const list2 = (params) => {
    return new Promise((resolve) => {
        let res = {
            data: {
                code:200,
                    rows:[
                    {id:1 , name:'张三1',age:16},
                    {id:2 , name:'张三2',age:20},
                    {id:3 , name:'张三3',age:16},
                    {id:4 , name:'张三4',age:20},
                    {id:5 , name:'张三5',age:20},
                    {id:6 , name:'张三6',age:16},
                    {id:7 , name:'张三7',age:20},
                    {id:8 , name:'张三8',age:16},
                    {id:9 , name:'张三9',age:16},
                    {id:10 , name:'张三10',age:21}, 
                ],
                total: 11
            } 
        }  
        resolve(
            res
        )
    })
} 

     const {
         createApp,
         ref,
         toRefs,
         reactive,
         onMounted,
         getCurrentInstance
     } = Vue
     const app = createApp({
         setup() {
             //列表查询
             const queryLisPromise = (query) => {
                 let queryClone = {
                     ...state.queryForm,
                     page: useListObj.page.value.currentPage,
                     per_page: useListObj.page.value.pageSize, 
                 } 
                 return new Promise((resolve, reject) => {
                     state.loading = true 
                    //  axios.get('/xxxx/list', { 
                    //          params: queryClone
                    //      })
                         list2(queryClone).then(res => {
                             state.loading = false  
                            if(res.data && res.data.code === 200) { 
                                state.tableData = res.data.rows; 
                                resolve({
                                    tableData: res.data.rows,
                                    total: res.data.total,
                                });
                            } else {
                                reject({
                                    tableData: [],
                                    total: 0,
                                });
                            } 
                         })
                         .catch(error => {
                             state.loading = false
                             reject({
                                 tableData: [],
                                 total: 0,
                             });
                         })
                 })
             }
             const useListObj = useList(queryLisPromise, item => item.id);
             const state = reactive({
                 queryForm: {
                    
                 },
                 loading: false,
                 postLoading: false, 
                 tableData: [],
                 postForm: {
                    name: "",
                    age: 0,
                 },
                 postRules: {
                     name: [{
                         required: true,
                         message: '姓名不能为空',
                         trigger: 'blur'
                     }], 
                 },
                 open: false,
                 operate: 'add', // add or edit 
                 queryRef: null,
                 postRef: null
             });
             const funObj = {
                 resetQuery() {
                     state.queryRef.resetFields();
                     useListObj.resetPage()
                     useListObj.handleQuery();
                 }, 
                 handleAdd() {
                     if (state.postRef) {
                         state.postRef.resetFields();
                     }
                     state.operate = 'add'
                     state.open = true;
                 }, 
                  submitForm() {  
                        state.postRef.validate( (baseValid) => { 
                            ElementPlus.ElMessage.success("新增成功")  
                         })
                 },
                 cancelForm() {
                     state.open = false;
                 },
                 handleDelete () {
                    console.log(useListObj.ids.value)
                    ElementPlus.ElMessage.success(`删除${useListObj.ids.value}`)
                 }
             } 

             onMounted(() => {
                useListObj.handleQuery();
             })

             return {
                 ...toRefs(state),
                 ...funObj,
                 ...useListObj
             }
         }
     })
     app.use(ElementPlus, {
         locale: window.ElementPlusLocaleZhCn
     });
     app.mount('#app')
 </script>

 <style scoped>
     .app-container {
         padding: 20px 0px 0 0px;
         background-color: #fff;
     }

     .el-table .el-table__header-wrapper th,
     .el-table__fixed-header-wrapper th {
         word-break: break-word;
         background-color: rgb(245, 245, 245) !important;
         color: #515a6e;
         height: 1.5384616;
         font-size: 13px;
         border: 1px solid #ebeef5 !important;
     }

     .el-table .el-table__body-wrapper {
         .el-button [class*="el-icon-"]+span {
             margin-left: 1px;
         }
     }

     .table-responsive {
         margin-top: 10px;
     }

     .el-form-item {
         margin-right: 0 !important;
         width: 100%
     }

     .el-form-item__content {
         width: calc(100% - 130px) !important
     }

     .el-form-item__label {
         line-height: 42px;
     } 

     .el-pagination {
         margin-top: 10px;
         margin-bottom: 10px;
         margin-left: 10px;
         margin-right: 10px;
     } 
     .searchOpreaBox {
         padding: 4px 0;
         padding-left: 5px;

     }
 </style>

自定义引入分页hooks文件list.js

image.png

public/js/plugin/vue3/hooks/list.js

function useList(queryLisPromise,handleSelectionChangeFun) {
  const state = Vue.reactive({
      page: {
          currentPage: 1, //服务端参数 当前几页
          pageSize: 10, //服务端参数 一页多少条
          total: 300, //服务端参数 一共有多少条
          pageSizes: [10, 20, 30, 50], //每页显示个数选择器的选项设置 客户端参数
          pagerCount: 6,  //最大页码按钮数 客户端参数
      }, 
      //恢复默认设置 使用
      pageBase: {  },  
      ids: [],
      single: false,
      multiple: false,
  }) ;
  state.pageBase = {...state.page} //复制一份
  const funObj = {
    async  handleQuery() {
      let tableData = []
      let total = 0
      try {
        let obj =  await queryLisPromise()
        tableData = obj.tableData
        total = obj.total
      } catch (error) {
        
      } 
      state.page.total = total; 
      state.single = false
      state.multiple = false
      return tableData
    }, 
    handleSizeChange(val) {
        state.page.pageSize = val
        if (state.page.currentPage * val > state.page.total) {
          state.page.currentPage = 1
        }
        funObj.handleQuery();
    },
    handleCurrentChange(val) {
        state.page.currentPage = val
        funObj.handleQuery();
    },
    resetPage() {
      state.page = {...state.pageBases} 
    },
    handleSelectionChange(selection) { 
      state.ids = selection.map(handleSelectionChangeFun); //item => item.id
      state.single = selection.length === 1;
      state.multiple = !!selection.length;
    }, 
  }

  return {...Vue.toRefs(state)
    ,...funObj 
  };
}

输出效果

xxxxx.cn/test/testvu… (http://xxxxx.cn改成你php访问的地址)

image.png

xxxxx.cn/test/list

image.png

源码参考

github.com/mjsong07/ph…

方案2: php + vite 单页面应用

分析

优点:

  1. 解决多页面应用的各个缺点
  2. 组件化,工程化,代码规范,单闭合标签问题,包含路由,后期项目成熟可以整体切换。

缺点:

  1. 前期构建花费时间长,需要动态生成对应的phtml文件,替换对应的请求文件路径,实现热更等
  2. 每次调试虽然也是刷新页面,但是需要等待构建,时间长一点。

思路

  1. 通过vite打包工具,每次修改代码都触发钩子函数,同步代码到php项目
  2. 把输出的index.html 同步到对应的 php项目views文件夹下对应phtml
    • 在不同的phtml页面根据配置,在全局window里面输出不同的__vue__router_path 变量
  3. 把资源文件js和css同步到php的public的vite文件夹下
  4. 页面加载时候,获取通过router.push(window.__vue__router_path)跳转到对应的路由模块

实战

1. 整体项目结构

image.png

2.php项目结构

image.png

  • application/controllers 转发控制器
  • application/views 编写view视图代码
  • public/js/plugin 存放自第三方js库
  • public/vite 存放vite生成的js和css

1 .controller

<?php
/**
 * 测试
 */

use Model\AclModel;
use Model\AreaModel;
use Model\MenuModel;
use Mvc\AbstractController;

class ResourceController extends AbstractController
{ 


    /**
     * 测试vue响应式
     * @throws Exception
     */
    public function testvue3Action()
    { 
    
    } 

    /**
     * 测试 增删改查
     * @throws Exception
     */
    public function listAction()
    { 
        
    }

}

2 .view试图

image.png

3 .资源文件

image.png

3.vite工程结构

由于php项目也是工程化,为了避免文件混杂,把vite工程单独一个文件夹vite在根目录

image.png

  • vite/ 存放整个vite工程项目代码
  • vite/plugin 构建同步的代码和配置
  • vite/src 具体的业务代码
同步构建的逻辑

image.png

image.png

每次构建

  • 都会把vite/dist中的assets复制到 public/vite/assets路径下,
  • 根据vite/plugins/syncToPhpList.json的配置,把vite/dist中的index.html复制到对应的php项目中的application/views/xxx/xxx.phtml

生成的 xxx.phtml 代码

<script>window.__vue__router_path = 'list'</script>
<script src="http://xxxx.cn/js/plugin/vue3/vue.global.min.js" crossorigin="anonymous"></script>
<script src="http://xxxx.cn/js/plugin/axios/axios.min.js" crossorigin="anonymous"></script>
<script src="http://xxxx.cn/js/plugin/elementplus/index.full.min.js" crossorigin="anonymous"></script>
<link href="http://xxxx.cn/js/plugin/elementplus/index.min.css" rel="stylesheet" crossorigin="anonymous">
<script type="module" crossorigin src="http://xxxx.cn/vite/assets/index-z7wQGBOr.js"></script>
<link rel="stylesheet" crossorigin href="http://xxxx.cn/vite/assets/index-BqLeIERi.css">
<div id="app"></div>
vite代码
核心配置与代码

image.png

vite/vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import {ElementPlusResolver} from 'unplugin-vue-components/resolvers' 
import cdnImport from 'vite-plugin-cdn-import';
import syncToPhp from './plugins/syncToPhp';
import { isProd, loadEnv } from './src/utils/vite' 
import { resolve } from 'path'
import { viteMockServe } from 'vite-plugin-mock'
import vueJsx from '@vitejs/plugin-vue-jsx';

export default defineConfig(({ mode, command }) => { 
   const { VITE_BASE_PATH } = loadEnv(mode)

  const pathResolve = (dir: string): any => {
    return resolve(__dirname, '.', dir)
  }
  
  const alias: Record<string, string> = {
    '@': pathResolve('./src/'),
    '~': pathResolve('./'),
    assets: pathResolve('./src/assets'), 
  }

  console.log("VITE_BASE_PATH",VITE_BASE_PATH)
  let baseUrl = VITE_BASE_PATH 

  return { 

    resolve: { alias },
    plugins: [vue(),vueJsx(),
      AutoImport({
        resolvers: [ElementPlusResolver()],
    }), Components({
        resolvers: [ElementPlusResolver()],
    }),
    viteMockServe({
      mockPath: './src/mock', 
    }),
  
    // 添加其他库的 CDN 配置
    cdnImport({
      modules: [
        {
          name: 'vue',
          var: 'Vue', 
          path: `${baseUrl}/js/plugin/vue3/vue.global.min.js`,
        },
        {
          name: "axios",
          var: "axios",
          path: `${baseUrl}/js/plugin/axios/axios.min.js`
        }, 
        {
          name: "element-plus",
          var: "ElementPlus",
          path: `${baseUrl}/js/plugin/elementplus/index.full.min.js`,
          css: `${baseUrl}/js/plugin/elementplus/index.min.css`
        },  
      ], 
    }),
    syncToPhp(), // 在每次构建都执行同步逻辑
    ],  
    base: `${baseUrl}/vite/`, 
    server: {
      // 端口号
      // port: VITE_PORT,
      host: "0.0.0.0",
      // 本地跨域代理 暂不使用
      proxy: {
        "/api": {
          target: "http://xxxxxx.cn",
          changeOrigin: true,
          rewrite: path => path.replace(/^\/api/, "")
        } 
      },  
    },
    build: {
      minify: false,
    },
    css: {
        // CSS 预处理器
        preprocessorOptions: {
            //define global scss variable
            scss: {
                javascriptEnabled: true,
                additionalData: `@import "src/styles/index.scss";`
            }
        }
    } 

  }
})

vite/plugins/syncToPhp.ts 同步php资源与文件代码

import { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import syncToPhpList from './syncToPhpList.json';

// 确保目录存在的函数
const ensureDirectoryExistence = (filePath:string) => {
  const dirname = path.dirname(filePath);
  if (fs.existsSync(dirname)) {
    return true;
  }
  ensureDirectoryExistence(dirname);
  fs.mkdirSync(dirname);
};

// 复制 HTML 文件的函数
const copyHtmlFiles = (sourceHtml:string, targetBaseHtmlDir:string, htmlContent:string) => {
  syncToPhpList.forEach(o => {
    const newHtmlContent = `<script>window.__vue__router_path = '${o.name}'</script>\n${htmlContent}`;
    const targetHtmlPath = path.join(targetBaseHtmlDir, o.path);
    ensureDirectoryExistence(targetHtmlPath);
    fs.writeFileSync(targetHtmlPath, newHtmlContent);
  });
};

// 复制 Assets 文件的函数
const copyAssetFiles = (sourceAssetDir:string, targetAssetDir:string) => {
  const files = fs.readdirSync(sourceAssetDir);
  files.forEach(file => {
    const sourcePath = path.join(sourceAssetDir, file);
    const targetPath = path.join(targetAssetDir, file);
    ensureDirectoryExistence(targetPath);
    fs.copyFileSync(sourcePath, targetPath);
  });
};

export default function syncToPhp(): Plugin {
  return {
    name: 'syncToPhp',
    apply: 'build', // 仅在构建时应用此插件
    closeBundle: async () => {
      console.log('同步代码到php项目-->>开始');
      const sourceHtml = './dist/index.html';
      const targetBaseHtmlDir = './../application/views';
      const sourceAssetDir = './dist/assets';
      const targetAssetDir = '../public/vite/assets';

      if (syncToPhpList.length === 0) {
        console.error('构建的列表不能为空');
        return;
      }

      try {
        const htmlContent = fs.readFileSync(sourceHtml, 'utf-8');
        copyHtmlFiles(sourceHtml, targetBaseHtmlDir, htmlContent);
        copyAssetFiles(sourceAssetDir, targetAssetDir);
      } catch (error) {
        console.error('构建异常', error);
      }

      console.log('同步代码到php项目-->>结束');
    }
  };
}


vite/plugins/syncToPhpList.json 根据文件配置映射生成

[
  { "name": "testvue3" , "path": "/test/testvue3.phtml"},
  { "name": "list" , "path": "/test/list.phtml"}
]

vite/src/App.vue

<script setup lang="ts"> 
import { zhCn } from "element-plus/es/locale/index.mjs";
import {  onMounted } from "vue";
import {  useRouter } from 'vue-router';

const router = useRouter();
onMounted(() => {
    //这里做vue的路由跳转
    console.log("window.__vue__router_path",window.__vue__router_path)
    if(window.__vue__router_path) {
        router.push(`/${window.__vue__router_path}`);
    }
})
</script>
<template>

  <el-config-provider :locale="zhCn">
    <router-view></router-view> 
  </el-config-provider>
  
</template> 

<style scoped lang="scss"> 
 
</style>

业务代码

vite/src/router/index.ts 路由

import  { createRouter,createWebHashHistory } from 'vue-router';
const testvue3 = () => import('../views/testvue3.vue')
const list = () => import('../views/list.vue') 

const routes = [
    { path: '/testvue3', component: testvue3 },  //
    { path: '/list', component: list }, //
];

const router = createRouter({
  history: createWebHashHistory(import.meta.env.BASE_URL),
  routes,
});

export default router;

vite/src/views/testvue3.vue

<template>
  <div> 
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>
<script setup lang="ts">
//列表查询
import { ref} from 'vue'
const count = ref(0);
const increment = () => {
  count.value++;
}; 
</script>
<style lang="scss">  
</style>

vite/src/views/list.vue

<template>
  <div>

    <el-form ref="queryRef" :model="queryForm" inline label-width="100px">
                 <el-row>
                     <el-col :xl="25" :lg="6" :md="8" :sm="12">
                         <el-form-item label="名称" prop="name">
                            <el-input v-model="queryForm.name" />
                         </el-form-item>
                     </el-col> 
                     <el-col :xl="25" :lg="6" :md="8" :sm="12">
                         <div class="searchOpreaBox">
                             <el-button type="primary" @click="handleQuery">搜索</el-button>
                             <el-button @click="resetQuery">重置</el-button>
                         </div>
                     </el-col>
                 </el-row>
                 <el-row :gutter="10" class="mb8">
                     <el-col :span="1.5">
                         <el-button type="primary" @click="handleAdd">新增</el-button>
                     </el-col> 
                     <el-col :span="1.5">
                         <el-button type="danger" plain :disabled="!multiple" @click="handleDelete">删除</el-button>
                     </el-col>
                 </el-row>
             </el-form> 
             <el-row class="">
                 <el-col>
                     <el-table :data="tableData" v-loading="loading" @selection-change="handleSelectionChange">
                         <el-table-column type="selection" width="50" align="center"> </el-table-column>
                         <el-table-column label="姓名" align="center" prop="name"></el-table-column>
                         <el-table-column label="年龄" align="center" prop="age"></el-table-column>
                     </el-table>
                 </el-col>
             </el-row>
             <Pagination :pagination="pagination" />
     <el-dialog title="新增/修改" v-model="open" width="500px" :close-on-click-modal="false">
         <el-form v-loading="postLoading" ref="postRef" :model="postForm" :rules="postRules" label-width="80px">
             <el-row>
                 <el-col>
                     <el-form-item label="姓名" prop="name">
                         <el-input v-model="postForm.name" />
                     </el-form-item>
                 </el-col> 
                 <el-col>
                     <el-form-item label="年龄" prop="range"> 
                            <el-input v-model="postForm.age" />
                     </el-form-item>
                 </el-col> 
             </el-row>
         </el-form> 
         <template #footer>
             <div class="dialog-footer">
                 <el-button type="primary" @click="submitForm">提交</el-button>
                 <el-button @click="cancelForm">取 消</el-button>
             </div>
         </template>
     </el-dialog>
  </div>
</template>
<script setup lang="ts">
//列表查询
import { reactive, toRefs ,onMounted} from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus/es'
import useList from '@/utils/list.ts' 
import Pagination from '@/components/Pagination.vue'; 
import {cloneDeep} from 'lodash'  
import api from '@/api/test' 
onMounted(() => { 
    handleQuery(); 
})

const state = reactive({   
  queryForm: {
                    
    },
    loading: false,
    postLoading: false, 
    tableData: [],
    postForm: {
        name: "",
        age: 0,
    },
    postRules: {
        name: [{
            required: true,
            message: '姓名不能为空',
            trigger: 'blur'
        }], 
    },
    open: false,
    operate: 'add', // add or edit 
    queryRef: null,
    postRef: null
});
const { queryForm, loading, postLoading,  tableData, postForm, postRules, open, operate, queryRef, postRef } = toRefs(state)

const queryLisPromise = () => {
  let queryClone = {
    ...queryForm.value, 
    page: page.value.currentPage,
    per_page: page.value.pageSize,
  }
  return new Promise((resolve, reject) => {
    loading.value = true  
    api.list2(queryClone).then(res => {
      loading.value = false     
      tableData.value = cloneDeep(res.rows);   
      resolve({
        tableData: res.rows,
        total: res.total,
      });  
    })
      .catch(_ => {
        loading.value = false
        reject({
          tableData: [],
          total: 0,
        });
      })
  })
}
 
const pagination  = useList(queryLisPromise, item => item.id);  
const { handleQuery, resetPage, ids,page,multiple,handleSelectionChange } = pagination; 

const resetQuery = () => {
  queryRef.value!.resetFields();
  resetPage()
  handleQuery();
};  
const handleAdd = () => {
  if (postRef.value) {
    postRef.value.resetFields();
  }
  operate.value = 'add'
  open.value = true;
}; 
const handleDelete = () => { 
  //开始删除
    ElMessage.success(`删除成功${ids.value}`)
};
const submitForm = () => {
  postRef.value.validate((baseValid) => {
    //开始提交
    ElMessage.success("提交成功")
  })
};
const cancelForm = () => {
  postLoading.value = false
  open.value = false;
}
</script>
<style lang="scss">  
</style>
测试
  1. 构建
cd vite #进入前端目录
npm install # 安装依赖
npm run build:dev # 构建开发环境
npm run build:dev-watch # 构建开发环境-同时实时更新构建 
npm run build:test # 构建测试
npm run build:staging # 构建预发布
npm run build:prod # 构建正式
  1. 访问 xxxxx.cn/test/list
  • 无论访问那个路由,都会先响应php的路由即 test(controller) 中的list方法,
  • 然后访问到application/views/test/list.phtml
  • 该页面会加载整个vue,然后根据提前输出的全局变量__vue__router_path,通过hash模式跳到vue路由里面的list
  • 最后url会变成 xxxxx.cn/test/list#l…
补充

有些资源可能需要用到php的标签访问 可以通过transformIndexHtml钩子使用正则替换处理

vite/plugins/sysncToPhp.ts

import { Plugin } from 'vite'; 
import fs from 'fs'; 
import path from 'path';   
import syncToPhpList from './syncToPhpList.json'

export default function syncToPhp(): Plugin {  
  return {
    name: 'syncToPhp',
    apply: 'build', // 仅在构建时应用此插件 
    //待处理  这里替换有bug
    transformIndexHtml(html) { 
      let result = html;
      console.log("html",html)
      result = html.replace(
        /<script\b[^>]*src="(\/assets\/[^"]+)"[^>]*><\/script>/g,
        (match, p1) => {
          const newPath = `/vite${p1}`;
          return `<script type="module" crossorigin src="<?= $this->helper->basePath('${newPath}'); ?>"></script>`;
        }
      ); 
      result = result.replace(
        /<link\b[^>]*href="(\/assets\/[^"]+)"[^>]*\/?>/g,
        (match, p1) => {
          const newPath = `/vite${p1}`;
          return `<link rel="stylesheet" href="<?= $this->helper->basePath('${newPath}'); ?>" />`;
        }
      );
      console.log('转化html结束'); 
      return result
    },
    closeBundle: async () => { 
        console.log('同步代码到php项目-->>开始'); 
        //...
        console.log('同步代码到php项目-->>结束');
    }  
  };
}

使用第三方cdn 可以修改 vite配置

vite/vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import {ElementPlusResolver} from 'unplugin-vue-components/resolvers' 
import cdnImport from 'vite-plugin-cdn-import';
import syncToPhp from './plugins/syncToPhp';
import { isProd, loadEnv } from './src/utils/vite' 
import { resolve } from 'path'
import { viteMockServe } from 'vite-plugin-mock'
import vueJsx from '@vitejs/plugin-vue-jsx';

export default defineConfig(({ mode, command }) => { 
   const { VITE_BASE_PATH } = loadEnv(mode)

  const pathResolve = (dir: string): any => {
    return resolve(__dirname, '.', dir)
  }
  
  const alias: Record<string, string> = {
    '@': pathResolve('./src/'),
    '~': pathResolve('./'),
    assets: pathResolve('./src/assets'), 
  }

  console.log("VITE_BASE_PATH",VITE_BASE_PATH)
  let baseUrl = VITE_BASE_PATH 

  return { 

    resolve: { alias },
    plugins: [vue(),vueJsx(),
      AutoImport({
        resolvers: [ElementPlusResolver()],
    }), Components({
        resolvers: [ElementPlusResolver()],
    }),
    viteMockServe({
      mockPath: './src/mock', 
    }),
  
    // 添加其他库的 CDN 配置
    cdnImport({
      prodUrl: "https://cdn.bootcdn.net/ajax/libs/{name}/{version}/{path}", 
      modules: [
        {
          name: 'vue',
          var: 'Vue', 
          path: 'vue.global.prod.min.js',
        },
        {
          name: "axios",
          var: "axios",
          path: "axios.min.js"
        }, 
        {
          name: "element-plus",
          var: "ElementPlus",
          path: "index.full.min.js",
          css: "index.min.css"
        },
      ], 
    }),
    syncToPhp(), // 在每次构建都执行同步逻辑
    ],   
    //...
  }
})

源码参考

github.com/mjsong07/ph…