代替Vue?Cli的全新腳手架工具create?vue示例解析
前言
美國時(shí)間 2021 年 10 月 7 日早晨,Vue 團(tuán)隊(duì)等主要貢獻(xiàn)者舉辦了一個(gè) Vue Contributor Days
在線會(huì)議,蔣豪群(知乎胖茶,Vue.js 官方團(tuán)隊(duì)成員,Vue-CLI 核心開發(fā)),在會(huì)上公開了create-vue,一個(gè)全新的腳手架工具。
create-vue
使用 npm init vue
一行命令就能快速的創(chuàng)建基于Vite的Vue3項(xiàng)目
npm init
$ npm init vue
以前我們初始化Vue-Cli項(xiàng)目時(shí)太多通過全局的形式安裝, 然后通過vue create project-name
命令進(jìn)行項(xiàng)目安裝,為什么npm init
也可以可以直接初始化一個(gè)項(xiàng)目且不需要全局安裝?
本質(zhì)是 npx 命令的語法糖,它在調(diào)用時(shí)是會(huì)轉(zhuǎn)換為npx 命令
npm init vue@next -> npx create-vue@next npm init @harexs -> npx @harexs/create npm init @harexs/test -> npx @harexs/create-test
看完這三個(gè)例子應(yīng)該就明白了 npm init 的作用了
npx
從本地或者遠(yuǎn)程npm包運(yùn)行命令
npx 就是一種調(diào)用npm包的命令,如果沒提供-c
或者--call
命令則默認(rèn)從我們指定的包中,查找package.json
中的 bin
字段,從而確定要執(zhí)行的文件
{ "name": "create-vue", "version": "3.3.4", "description": "An easy way to start a Vue project", "type": "module", "bin": { "create-vue": "outfile.cjs" //關(guān)鍵 } }
那npm init vue
完整的解析就是 本地或者遠(yuǎn)程尋找 create-vue
這個(gè)包,然后查找package.json
中 bin
字段值的可執(zhí)行文件,最終就是運(yùn)行了outfile.cjs
這個(gè)文件.
源碼
這里使用川哥的create-vue-analysis
倉庫,倉庫的版本便于我們學(xué)習(xí)其思路和實(shí)現(xiàn),對(duì)應(yīng)的是3.0.0-beta.6
版本。 最新版本已經(jīng)到了3.3.4
, 但核心功能以及實(shí)現(xiàn)思路是不變的.
主流程入口
//index.js async function init() { /// } init().catch((e) => { console.error(e) })
先不看其他部分, 先關(guān)注入口這里 就是 自調(diào)用了異步函數(shù) init
獲取參數(shù)
const cwd = process.cwd() //獲取當(dāng)前運(yùn)行環(huán)境項(xiàng)目目錄 //process.argv.slice(2) 用來獲取 npx create-vue 后面?zhèn)魅氲膮?shù) 值為數(shù)組 //minimist 用來格式化獲取傳入的參數(shù) const argv = minimist(process.argv.slice(2), { alias: { typescript: ['ts'], 'with-tests': ['tests', 'cypress'], router: ['vue-router'] }, // all arguments are treated as booleans boolean: true }) //通過minimist獲取的argv結(jié)果是個(gè)對(duì)象,通過對(duì)象屬性去判斷 是否有傳入?yún)?shù) const isFeatureFlagsUsed = typeof (argv.default || argv.ts || argv.jsx || argv.router || argv.vuex || argv.tests) === 'boolean' //argv._ 這個(gè)_ 屬性獲取的是 沒有-或者--開頭的參數(shù) //比如 npm init create-vue xxx --a 那么argv就是 {_:['xxx'],a:true} let targetDir = argv._[0] // argv._[0] 假如對(duì)應(yīng){_:['xxx'],a:true} 就是 xxx //給一會(huì)的選項(xiàng)用的 默認(rèn)項(xiàng)目名稱 defaultProjectName 默認(rèn)取targetDir const defaultProjectName = !targetDir ? 'vue-project' : targetDir const forceOverwrite = argv.force
接著是第二部分,主要就是獲取 運(yùn)行目錄 以及 判斷 命令調(diào)用時(shí) 有沒有傳入指定參數(shù)
對(duì)話選項(xiàng)
try { result = await prompts( [ { //name 參數(shù)就是一會(huì)要收集的對(duì)應(yīng)變量 name: 'projectName', //判斷targetDir 有沒有值 有值的話 就是null null會(huì)跳過這個(gè)對(duì)話 type: targetDir ? null : 'text', message: 'Project name:', initial: defaultProjectName, //默認(rèn)結(jié)果值 獲取參數(shù)部分已經(jīng)說過這個(gè)變量了 //onState 完成回調(diào),讓targetDir 取 用戶輸入的內(nèi)容 沒輸入直接回車的話 取defaultProjectName onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName) }, { name: 'shouldOverwrite', //canSafelyOverwrite 判斷是否是空目錄并可以寫入 否則判斷有沒有參數(shù)--force 目錄 // 有一個(gè)條件有效就為null 就跳過寫入對(duì)話, 否則為confirm 確認(rèn)框 y/n type: () => (canSafelyOverwrite(targetDir) || forceOverwrite ? null : 'confirm'), message: () => { //提示文本 const dirForPrompt = targetDir === '.' ? 'Current directory' : `Target directory "${targetDir}"` return `${dirForPrompt} is not empty. Remove existing files and continue?` } }, { name: 'overwriteChecker', //檢查是否寫入, type這里的函數(shù) prev 是上一個(gè)選項(xiàng)的值, values 是整個(gè)對(duì)象 //如果 shouldOverwrite 階段 type變?yōu)? confirm 并且還選了 no //那么這一階段判斷后就會(huì)直接退出 不再執(zhí)行 拋出異常 type: (prev, values = {}) => { if (values.shouldOverwrite === false) { throw new Error(red('?') + ' Operation cancelled') } return null } }, { name: 'packageName', //正則驗(yàn)證 是否符合 package.json name 的值,不符合則讓用戶輸入 type: () => (isValidPackageName(targetDir) ? null : 'text'), message: 'Package name:', //沒輸入則取默認(rèn)值 將targetDir 通過函數(shù)轉(zhuǎn)換為符合packageName的格式 initial: () => toValidPackageName(targetDir), //校驗(yàn)函數(shù),如果 用戶輸入的包名無法通過 則提示Invalid package.json name 重新輸入 validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name' }, { name: 'needsTypeScript', type: () => (isFeatureFlagsUsed ? null : 'toggle'), message: 'Add TypeScript?', initial: false, active: 'Yes', inactive: 'No' }, { name: 'needsJsx', type: () => (isFeatureFlagsUsed ? null : 'toggle'), message: 'Add JSX Support?', initial: false, active: 'Yes', inactive: 'No' }, { name: 'needsRouter' //toggle 和confirm 無異 isFeatureFlagsUsed 如果有指定某一參數(shù) 則跳過后面所有對(duì)話, type: () => (isFeatureFlagsUsed ? null : 'toggle'), message: 'Add Vue Router for Single Page Application development?', initial: false, active: 'Yes', inactive: 'No' }, { name: 'needsVuex', type: () => (isFeatureFlagsUsed ? null : 'toggle'), message: 'Add Vuex for state management?', initial: false, active: 'Yes', inactive: 'No' }, { name: 'needsTests', type: () => (isFeatureFlagsUsed ? null : 'toggle'), message: 'Add Cypress for testing?', initial: false, active: 'Yes', inactive: 'No' } ], { onCancel: () => { throw new Error(red('?') + ' Operation cancelled') } } ) } catch (cancelled) { console.log(cancelled.message) process.exit(1) }
這一部分 使用了prompts
這個(gè)庫, 它提供了 命令行對(duì)話選項(xiàng)的能力, 這里主要收集用戶的選擇以及輸入,
默認(rèn)值
//取出前面對(duì)話選項(xiàng)后的值, 如果沒有的話 就取 argv上的默認(rèn)值 const { packageName = toValidPackageName(defaultProjectName), shouldOverwrite, needsJsx = argv.jsx, needsTypeScript = argv.typescript, needsRouter = argv.router, needsVuex = argv.vuex, needsTests = argv.tests } = result //root為 命令運(yùn)行位置 + targetDir 得到 項(xiàng)目路徑 const root = path.join(cwd, targetDir) //如果之前判斷的文件目錄可寫入 則執(zhí)行一次emptyDir if (shouldOverwrite) { emptyDir(root) // 再判斷目錄不存在則創(chuàng)建這個(gè)目錄 } else if (!fs.existsSync(root)) { fs.mkdirSync(root) } console.log(`\nScaffolding project in ${root}...`) const pkg = { name: packageName, version: '0.0.0' } //往root目錄 寫入初始化 package.json文件 fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(pkg, null, 2))
emptyDir函數(shù)
function emptyDir(dir) { postOrderDirectoryTraverse( dir, (dir) => fs.rmdirSync(dir), (file) => fs.unlinkSync(file) ) }
emptyDir
內(nèi)部調(diào)用 postOrderDirectoryTraverse
函數(shù),它來自u(píng)tils下 我們接著看
export function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) { //fs.readdirSync(dir) 返回一個(gè)數(shù)組 包含當(dāng)前目錄下的文件名 列表 for (const filename of fs.readdirSync(dir)) { //遍歷列表 得到 文件的完整路徑 const fullpath = path.resolve(dir, filename) if (fs.lstatSync(fullpath).isDirectory()) { // 如果這個(gè)文件 也是個(gè)目錄 則遞歸繼續(xù)遍歷 //因?yàn)閯h除目錄的話 必須要先刪除所有文件 postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback) //執(zhí)行記dirCallback 回調(diào) 也就是fs.rmdirSync(dir) 移除目錄 dirCallback(fullpath) continue } //否則調(diào)用第二個(gè)回調(diào) 就是移除文件 fileCallback(fullpath) } }
emptyDir
就是對(duì)目錄 遞歸遍歷,遇到目錄就繼續(xù)遞歸遍歷然后刪除目錄,文件就直接刪除
模板寫入
const templateRoot = path.resolve(__dirname, 'template') const render = function render(templateName) { const templateDir = path.resolve(templateRoot, templateName) renderTemplate(templateDir, root) } // Render base template render('base') // Add configs. if (needsJsx) { render('config/jsx') } if (needsRouter) { render('config/router') } if (needsVuex) { render('config/vuex') } if (needsTests) { render('config/cypress') } if (needsTypeScript) { render('config/typescript') } // Render code template. // prettier-ignore const codeTemplate = (needsTypeScript ? 'typescript-' : '') + (needsRouter ? 'router' : 'default') render(`code/${codeTemplate}`) // Render entry file (main.js/ts). if (needsVuex && needsRouter) { render('entry/vuex-and-router') } else if (needsVuex) { render('entry/vuex') } else if (needsRouter) { render('entry/router') } else { render('entry/default') }
先看第一部分
// work around the esbuild issue that `import.meta.url` cannot be correctly transpiled // when bundling for node and the format is cjs // const templateRoot = new URL('./template', import.meta.url).pathname const templateRoot = path.resolve(__dirname, 'template') //需要區(qū)分的是 templateDir取的是 對(duì)應(yīng)當(dāng)前執(zhí)行文件環(huán)境中的文件地址 // root變量 path.join(cwd, targetDir) process.cwd() 也就是取的命令執(zhí)行時(shí)的地址 //到時(shí)候?qū)?yīng)的可能就是這樣: //C:xxx/xxxx/npm-cache/_npx/xxxx/.bin/create-vue/template //D:/xxx/projectDir/vue-project const render = function render(templateName) { const templateDir = path.resolve(templateRoot, templateName) renderTemplate(templateDir, root) }
需要注意 __dirname
是不存在會(huì)報(bào)錯(cuò)的,作者在注釋也留有信息, 因?yàn)槲覀兊捻?xiàng)目環(huán)境是ESM
,原先CJS
的環(huán)境變量不能用了, 所以我們要換成這種寫法
import path from "node:path"; import url from "node:url"; const __filename = url.fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); console.log(__filename, __dirname);
node:xxx
這種寫法是Node提供的, 我在 promiseify
文章中也有講到過
接下里看 renderTemplate
函數(shù)
./utils/renderTemplate.js
function renderTemplate(src, dest) { //src 是 npx拉取到create-vue本地緩存目錄中的 模板目錄地址 //dest 是 用戶命令執(zhí)行時(shí)的 項(xiàng)目地址 const stats = fs.statSync(src) //statSync 返回一個(gè)文件類對(duì)象 可以看下面的截圖 if (stats.isDirectory()) { // if it's a directory, render its subdirectories and files recusively //如果src 是一個(gè)目錄 則 在對(duì)應(yīng)dest 的位置創(chuàng)建一個(gè)目錄 //recursive: true 允許遞歸創(chuàng)建目錄 也就是允許 a/b/c 這種形式來創(chuàng)建 fs.mkdirSync(dest, { recursive: true }) for (const file of fs.readdirSync(src)) { //遍歷src中所有文件, 遞歸自身,傳入的參數(shù)變?yōu)?src/file 也即是每個(gè)文件名 //第二個(gè)參數(shù) 對(duì)應(yīng)的就是 dest/file renderTemplate(path.resolve(src, file), path.resolve(dest, file)) } return } //這一步就是循環(huán)遍歷 將src中的每個(gè)文件包目錄都寫入到 我們聲明的project(dest)項(xiàng)目中 //如果src不是一個(gè)目錄 則來到這里 先取出文件名 const filename = path.basename(src) //判斷文件名是否為 package.json 并且 dest 也對(duì)應(yīng)存在這個(gè)文件 if (filename === 'package.json' && fs.existsSync(dest)) { // merge instead of overwriting //讀取兩個(gè)package.json的內(nèi)部 const existing = JSON.parse(fs.readFileSync(dest)) const newPackage = JSON.parse(fs.readFileSync(src)) //合并兩個(gè)文件的內(nèi)并 并重新排序 得到新的 package.json內(nèi)容 const pkg = sortDependencies(deepMerge(existing, newPackage)) //重新寫入到 dest下 fs.writeFileSync(dest, JSON.stringify(pkg, null, 2) + '\n') return } if (filename.startsWith('_')) { // rename `_file` to `.file` //resolve 合并名字 //dirname 取目錄名字 dest = path.resolve(path.dirname(dest), filename.replace(/^_/, '.')) } //拷貝文件 fs.copyFileSync(src, dest) }
接下來解析合并以及排序package.json
的工具函數(shù)
//判斷是不是對(duì)象 const isObject = (val) => val && typeof val === 'object' //合并數(shù)組值 通過new Set 去除重復(fù)項(xiàng) const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b])) /** * Recursively merge the content of the new object to the existing one * @param {Object} target the existing object * @param {Object} obj the new object */ function deepMerge(target, obj) { for (const key of Object.keys(obj)) { const oldVal = target[key] const newVal = obj[key] if (Array.isArray(oldVal) && Array.isArray(newVal)) { //合并數(shù)組項(xiàng) target[key] = mergeArrayWithDedupe(oldVal, newVal) } else if (isObject(oldVal) && isObject(newVal)) { //如果是對(duì)象則遞歸自身 繼續(xù)遍歷 target[key] = deepMerge(oldVal, newVal) } else { //否則直接覆蓋值 target[key] = newVal } } return target } export default deepMerge
比較簡單的深拷貝函數(shù)
export default function sortDependencies(packageJson) { // packageJson json的內(nèi)容對(duì)象 // sorted排序字段 const sorted = {} //需要排序的類型 const depTypes = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] for (const depType of depTypes) { //如果json中包含了 這個(gè)字段 if (packageJson[depType]) { //賦值sorted對(duì)應(yīng)的 depType 為空對(duì)象 sorted[depType] = {} Object.keys(packageJson[depType]) //得到packageJson depType所有key的數(shù)組 .sort() //使用默認(rèn)排序 .forEach((name) => { //遍歷這個(gè)key的數(shù)組 然后將對(duì)應(yīng)的值 重新賦值到sorted depType name sorted[depType][name] = packageJson[depType][name] }) } } //ES6 展開對(duì)象語法 重復(fù)的key 會(huì)被覆蓋 達(dá)到排序的效果 return { ...packageJson, ...sorted } }
sortDependencies
核心就是通過聲明指定字段數(shù)組 然后取出來給到新的對(duì)象,然后通過ES6的展開語法 同樣的key 新key覆蓋舊key的應(yīng)用實(shí)現(xiàn)了排序效果
又學(xué)到一招,展開語法真好使啊~
// Add configs. if (needsJsx) { render('config/jsx') } if (needsRouter) { render('config/router') } if (needsVuex) { render('config/vuex') } if (needsTests) { render('config/cypress') } if (needsTypeScript) { render('config/typescript') } // Render code template. // prettier-ignore const codeTemplate = (needsTypeScript ? 'typescript-' : '') + (needsRouter ? 'router' : 'default') render(`code/${codeTemplate}`) // Render entry file (main.js/ts). if (needsVuex && needsRouter) { render('entry/vuex-and-router') } else if (needsVuex) { render('entry/vuex') } else if (needsRouter) { render('entry/router') } else { render('entry/default') }
在看這一段,基本能明白主要是在做什么操作。通過前面收集的變量進(jìn)行render
if (needsTypeScript) { // rename all `.js` files to `.ts` // rename jsconfig.json to tsconfig.json //前面說過 preOrderDirectoryTraverse函數(shù),前面是通過遍歷的形式 去移除文件和目錄 //這里的調(diào)用只傳入第二個(gè)參數(shù)就是針對(duì)非目錄的文件 preOrderDirectoryTraverse( root, () => {}, (filepath) => { //看作者的注釋也很好理解,遇到.js結(jié)尾文件 重寫為ts if (filepath.endsWith('.js')) { fs.renameSync(filepath, filepath.replace(/\.js$/, '.ts')) //遇到j(luò)sconfig.json 重寫為 tsconfig.json } else if (path.basename(filepath) === 'jsconfig.json') { fs.renameSync(filepath, filepath.replace(/jsconfig\.json$/, 'tsconfig.json')) } } ) // Rename entry in `index.html //取到 index.html 文件路徑 const indexHtmlPath = path.resolve(root, 'index.html') //通過utf-8 讀取 文件內(nèi)容 const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8') //將原本引用的 main.js 重寫為 main.ts 這里也很好理解 fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace('src/main.js', 'src/main.ts')) }
export function preOrderDirectoryTraverse(dir, dirCallback, fileCallback) { for (const filename of fs.readdirSync(dir)) { //遍歷dir const fullpath = path.resolve(dir, filename) if (fs.lstatSync(fullpath).isDirectory()) { dirCallback(fullpath) //如果是目錄 則 調(diào)用dirCallback // in case the dirCallback removes the directory entirely //執(zhí)行完后再判斷 目錄是否還存在 再遞歸自身繼續(xù)調(diào)用 if (fs.existsSync(fullpath)) { preOrderDirectoryTraverse(fullpath, dirCallback, fileCallback) } continue } fileCallback(fullpath) } }
preOrderDirectoryTraverse
稍有不同,對(duì)于目錄級(jí)的回調(diào)會(huì)先執(zhí)行然后再判斷目錄是否還存在,在進(jìn)行遞歸
if (!needsTests) { // All templates assumes the need of tests. // If the user doesn't need it: // rm -rf cypress **/__tests__/ preOrderDirectoryTraverse( root, (dirpath) => { //對(duì)于目錄 得到目錄名 const dirname = path.basename(dirpath) //如果目錄名為cypress || __tests__ if (dirname === 'cypress' || dirname === '__tests__') { emptyDir(dirpath) //執(zhí)行 清空目錄操作 fs.rmdirSync(dirpath) //最后移除這個(gè)目錄 } }, () => {} ) }
//通過npm_execpath來獲取當(dāng)前執(zhí)行的包管理器絕對(duì)路徑 //得到路徑后 通過 關(guān)鍵 字符去匹配 const packageManager = /pnpm/.test(process.env.npm_execpath) ? 'pnpm' : /yarn/.test(process.env.npm_execpath) ? 'yarn' : 'npm' // README generation //生產(chǎn)README generateReadme 也比較簡單不展開講它了 fs.writeFileSync( path.resolve(root, 'README.md'), generateReadme({ projectName: result.projectName || defaultProjectName, packageManager, needsTypeScript, needsTests }) ) console.log(`\nDone. Now run:\n`) if (root !== cwd) { console.log(` ${bold(green(`cd ${path.relative(cwd, root)}`))}`) } console.log(` ${bold(green(getCommand(packageManager, 'install')))}`) console.log(` ${bold(green(getCommand(packageManager, 'dev')))}`) console.log()
簡述
至此,整個(gè)源碼就解析完畢了,剛開始確實(shí)感覺很復(fù)雜,畢竟Node很多API都不是很熟悉, 然后就一一拆解開來對(duì)著文檔慢慢看了,讀源碼還是很需要耐心~
大體的流程:
- 收集用戶指定的參數(shù) 以及 項(xiàng)目名
- 通過對(duì)話選項(xiàng)卡確定用戶的配置
- 根據(jù)對(duì)話選項(xiàng)卡后用戶的配置匹配模板目錄下的文件,一一寫入到項(xiàng)目文件夾中
- 再判斷是否需要Ts/測(cè)試 對(duì)文件做修改
- 生成其他文件 流程結(jié)束
快照
項(xiàng)目中還有個(gè)snapshot.js
文件, 它主要是通過const featureFlags = ['typescript', 'jsx', 'router', 'vuex', 'with-tests']
組合生成 31種加上default
共計(jì) 32種組合,然后通過子線程命令spawnSync
調(diào)用 bin
然后循環(huán)把 我們組合好的 參數(shù)傳給它執(zhí)行,也就是相當(dāng)于執(zhí)行了npm init vue
這一步操作并傳入組合好的參數(shù)
最后生成不同的模板在 playground
目錄中
關(guān)于這個(gè)命令: spawnSync
總結(jié)
在寫完本文的時(shí)候,把源碼大概梳理了2-3遍, 讀源碼很需要耐心, 遇到不懂的API還要翻文檔, 但是等你完全弄明白里面的原理和實(shí)現(xiàn)思路之后,就有一種油然而生的開心, 而且以后要開發(fā)類似的腳手架時(shí) 也可以有一定的思路和想法去實(shí)現(xiàn)它!
以上就是代替Vue Cli的全新腳手架工具create vue示例解析的詳細(xì)內(nèi)容,更多關(guān)于Vue Cli腳手架工具create vue的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
vue頁面監(jiān)聽是否置為后臺(tái)或可見狀態(tài)問題
這篇文章主要介紹了vue頁面監(jiān)聽是否置為后臺(tái)或可見狀態(tài)問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-10-10vue中實(shí)現(xiàn)子組件相互切換且數(shù)據(jù)不丟失的策略詳解
項(xiàng)目為數(shù)據(jù)報(bào)表,但是一個(gè)父頁面中有很多的子頁面,而且子頁面中不是相互關(guān)聯(lián),但是數(shù)據(jù)又有聯(lián)系,所以本文給大家介紹了vue中如何實(shí)現(xiàn)子組件相互切換,而且數(shù)據(jù)不會(huì)丟失,并有詳細(xì)的代碼供大家參考,需要的朋友可以參考下2024-03-03Vue+element-ui 實(shí)現(xiàn)表格的分頁功能示例
這篇文章主要介紹了Vue+element-ui 實(shí)現(xiàn)表格的分頁功能示例,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-08-08Element-ui?DatePicker日期選擇器基礎(chǔ)用法示例
這篇文章主要為大家介紹了Element-ui?DatePicker日期選擇器基礎(chǔ)用法示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-06-06vue3深入學(xué)習(xí)?nextTick和historyApiFallback
這篇文章主要介紹了vue3深入學(xué)習(xí)?nextTick和historyApiFallback,文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價(jià)值,需要的朋友可以參考一下2022-08-08使用vue-router與v-if實(shí)現(xiàn)tab切換遇到的問題及解決方法
這篇文章主要介紹了vue-router與v-if實(shí)現(xiàn)tab切換的思考,需要的朋友可以參考下2018-09-09Vue 使用html、css實(shí)現(xiàn)魚骨組件圖
這篇文章主要介紹了Vue 使用html、css實(shí)現(xiàn)魚骨組件圖,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),感興趣的朋友跟隨小編一起看看吧2024-07-07