⚡ vue3 全家桶体验

前置

从创建一个简单浏览器导航首页项目展开,该篇随笔包含以下内容的简单上手

  • vite
  • vue3
  • vuex4
  • vue-router next

预览效果有助于理清这些内容,限于篇幅,不容易展开叙述。由于项目逻辑简单,只使用了少量 API,我只是写这个小项目过把手瘾,所以对应标题 上手。如果您只是想学习 vue 周边的 API,那么,这篇文章将给您带来有限的知识。

初始化项目

使用 vite 初始化 vue3 项目。什么是 vite?Vite 是一个 Web 构建工具。开发过程中通过浏览器 ES Module 导入为您的代码提供服务,生成环境与 Rollup 捆绑在一起进行打包。

特性:

  • 闪电般快速的冷服务器启
  • 动即时热模块更换(HMR)
  • 真正的按需编译

vite 截至今天支持的功能:

  • Bare Module Resolving
  • Hot Module Replacement
  • TypeScript
  • CSS / JSON Importing
  • Asset URL Handling
  • PostCSS
  • CSS Modules
  • CSS Pre-processors
  • JSX
  • Web Assembly
  • Inline Web Workers
  • Custom Blocks
  • Config File
  • HTTPS/2
  • Dev Server Proxy
  • Production Build
  • Modes and Environment Variables
npm init vite-app aweshome
npm install
npm run dev
npm run build

最终生成的目录结构与使用 vue-cli 相似:

│  .npmignore
│  a.txt
│  index.html
│  package.json
├─public
│      favicon.ico
└─src
    │  App.vue
    │  index.css
    │  main.js
    ├─assets
    │      logo.png
    └─components
            HelloWorld.vue

可以在项目根目录下创建 vite.config.js 配置 Vite:

module.exports = {
  // 导入别名
  // 这些条目可以是精确的请求->请求映射*(精确,无通配符语法)
  // 也可以是请求路径-> fs目录映射。 *使用目录映射时
  // 键**必须以斜杠开头和结尾**
  alias: {
    // 'react': '@pika/react',
    // 'react-dom': '@pika/react-dom'
    // '/@foo/': path.resolve(__dirname, 'some-special-dir'),
  },
  // 配置Dep优化行为
  optimizeDeps: {
    // exclude: ['dep-a', 'dep-b'],
  },
  // 转换Vue自定义块的功能。
  vueCustomBlockTransforms: {
    // i18n: src => `export default Comp => { ... }`,
  },
  // 为开发服务器配置自定义代理规则。
  proxy: {
    // proxy: {
    //   '/foo': 'http://localhost:4567/foo',
    //   '/api': {
    //     target: 'http://jsonplaceholder.typicode.com',
    //     changeOrigin: true,
    //     rewrite: path => path.replace(/^\/api/, ''),
    //   },
    // },
  },
  // ...
}

更多配置可以参考Github

另外,现在可以使用 vitepress 代替原来的 vuepress 构建文档或博客。

vue-router next

npm i vue-router@next

src/router/index.js

import {createRouter, createWebHistory} from 'vue-router'
import Home from '../components/home/Home.vue'
import Cards from '../components/cards/Cards.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    // route -> routes
    {
      path: '/',
      name: 'home',
      component: Home,
    },
    {
      path: '/cards',
      name: 'cards',
      component: Cards,
    },
  ],
})

export default router

vue router next 还添加了动态路由,解决规则冲突的问题。做过权限管理应该深有体会。更多配置可以参考 Github

vuex4

使用与 vuex3 相同的 API。

安装

npm i vuex@next

src/constants 下存放了静态数据,它们都是如下形式:

export const vue = [
  {
    title: 'vue',
    desc: 'Vue 是用于构建用户界面的渐进式的框架',
    link: 'https://cn.vuejs.org/v2/guide/',
    img: import('../assets/images/vue.png'), // require -> import
  },
  {
    title: 'vue Router',
    desc: 'Vue Router 是 Vue.js 官方的路由管理器。',
    link: 'https://router.vuejs.org/zh/',
    img: import('../assets/images/vue.png'),
  },
  // ...
]

src/store/index.js

import {createStore} from 'vuex'

import {vue, react, wechat, across, compileBuild} from '../constants/docs'
import {frontEndTools, OfficeTools} from '../constants/tools'
import {tools, docs, community} from '../constants/asideData'
import {blogs} from '../constants/community'

const store = createStore({
  state: {
    asideData: [],
    mainData: [],
  },
  mutations: {
    setAsideData(state, key) {
      const asideActions = {
        '2': tools,
        '3': docs,
        '4': community,
      }
      state.asideData = asideActions[key]
    },
    setMainData(state, menuItemText) {
      const actions = new Map([
        ['前端工具', frontEndTools],
        ['办公工具', OfficeTools],
        ['vue', vue],
        ['react', react],
        ['微信开发', wechat],
        ['跨端框架', across],
        ['编译构建', compileBuild],
        ['博客', blogs],
      ])
      state.mainData = actions.get(menuItemText)
    },
  },
  actions: {},
  modules: {},
})

export default store

main.js

结合上文的 vuex vue-router 可以看出,vue3 核心插件的 api 都做了简化。

import './index.css'
import {createApp} from 'vue'
import store from './store'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(store)
app.use(router)
app.mount('#app')

sass

npm i sass

package.json > dependencies

{
  "dependencies": {
    "vue": "^3.0.0-beta.15",
    "vue-router": "^4.0.0-alpha.13",
    "vuex": "^4.0.0-beta.2"
  },
  "devDependencies": {
    "@vue/compiler-sfc": "^3.0.0-beta.15",
    "sass": "^1.26.8",
    "vite": "^1.0.0-beta.1"
  }
}

components

这个小项目本质上可以只有一个页面 .vue 构成,我将它拆分,便于阅读。

App.vue
<template>
  <Header />
  <main>
    <router-view></router-view>
  </main>
  <Footer />
</template>

<script>
import Header from './components/Header.vue'
import Footer from './components/Footer.vue'

export default {
  name: 'app',
  components: {
    Header,
    Footer,
  },
}
</script>

<style>
main {
  flex: 1;
}
</style>
components/cards/Aside.vue
<template>
  <aside>
    <ul>
      <li :index="item.index" v-for="item in this.$store.state.asideData" :key="item.index" ref="menuItem" @click="handleSelect(item.value)">
        <i class="fas fa-home"></i>
        <span>{{ item.value }}</span>
      </li>
    </ul>
  </aside>
</template>

<script>
import store from '../../store'

export default {
  setup(props, context) {
    return {
      handleSelect(value) {
        store.commit('setMainData', value)
      },
    }
  },
}
</script>

<style lang="scss">
aside {
  flex: 1;
  background-color: rgb(238, 238, 238);
  height: 100%;
  li {
    display: flex;
    align-items: center;
    height: 56px;
    line-height: 56px;
    font-size: 14px;
    color: #303133;
    padding: 0 1.4rem;
    list-style: none;
    cursor: pointer;
    transition: border-color 0.3s, background-color 0.3s, color 0.3s;
    white-space: nowrap;
    &:hover {
      background-color: rgb(224, 224, 224);
    }
  }
}

@media screen and (max-width: 768px) {
  aside {
    display: none;
    &.active {
      display: block;
    }
  }
}
</style>
components/cards/Cards.vue
<template>
  <div id="card-outer">
    <Aside />
    <section></section>
  </div>
</template>

<script>
import Aside from './Aside.vue'
import router from '../../router'

export default {
  components: {
    Aside,
  },
}
</script>

<style lang="scss">
#card-outer {
  display: flex;
  align-content: stretch;
  height: 100%;
  & > section {
    flex: 8;
  }
}

.main-card {
  margin: 10px 0;
  cursor: pointer;
  .main-card-content {
    display: flex;
    align-items: center;
    img {
      width: 30px;
      height: 30px;
      margin-right: 10px;
    }
    .main-card-content-info {
      width: 90%;
      h3 {
        font-size: 14px;
      }
      p {
        font-size: 12px;
        color: #888ea2;
        white-space: nowrap;
        text-overflow: ellipsis;
        overflow: hidden;
        width: 100%;
        line-height: 1.8;
      }
    }
    span {
      margin-left: 10px;
      text-decoration: none;
      &:nth-of-type(1) {
        font-size: 18px;
        font-weight: 700;
        color: #ffa502;
        white-space: nowrap;
      }
      &:nth-of-type(2) {
        font-size: 14px;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
    }
  }
}
</style>
components/home/Home.vue
<template>
  <section id="search">
    <div class="search-sources" style="margin-bottom: 10px;">
      <span size="mini" type="primary" v-for="(item, index) in source" @click="changeSource(item.name)" :key="index" :style="`background:${item.color};border-color:${item.color}`"
        >{{ item.name }}
      </span>
    </div>
    <div class="searchbox" :class="searchbarStyle.className">
      <input :placeholder="searchbarStyle.placeholder" v-model="searchValue" clearable v-on:keyup.enter="submit" />
      <button @click="submit" slot="append" icon="el-icon-search">
        <i class="fas fa-search"></i>
      </button>
    </div>
  </section>
</template>

<script>
export default {
  data: () => ({
    baseUrl: 'https://www.baidu.com/s?ie=UTF-8&wd=',
    searchValue: '',
    searchbarStyle: {
      className: 'baidu',
      placeholder: '百度一下,你就知道',
    },
    source: [
      {
        name: '百度',
        color: '#2932E1',
      },
      {
        name: '搜狗',
        color: '#FF6F17',
      },
      {
        name: 'Bing',
        color: '#0c8484',
      },
      {
        name: 'Google',
        color: '#4285F4',
      },
      {
        name: 'NPM',
        color: '#EA4335',
      },
    ],
  }),
  methods: {  // 可以在 vue3 中使用 options API
    changeSource(name) {
      const actions = new Map([
        [
          '百度',
          () => {
            this.baseUrl = 'https://www.baidu.com/s?ie=UTF-8&wd='
            this.searchbarStyle = {
              className: 'baidu',
              placeholder: '百度一下,你就知道',
            }
          },
        ],
        [
          'Bing',
          () => {
            this.baseUrl = 'https://cn.bing.com/search?FORM=BESBTB&q='
            this.searchbarStyle = {
              className: 'bing',
              placeholder: '必应搜索',
            }
          },
        ],
        [
          '搜狗',
          () => {
            this.baseUrl = 'https://www.sogou.com/web?query='
            this.searchbarStyle = {
              className: 'sougou',
              placeholder: '搜狗搜索',
            }
          },
        ],
        [
          'Google',
          () => {
            this.baseUrl = 'https://www.google.com/search?q='
            this.searchbarStyle = {
              className: 'google',
              placeholder: 'Google Search',
            }
          },
        ],
        [
          'NPM',
          () => {
            this.baseUrl = 'https://www.npmjs.com/search?q='
            this.searchbarStyle = {
              className: 'npm',
              placeholder: 'Search Packages',
            }
          },
        ],
      ])
      actions.get(name)()
    },
    submit() {
      const url = this.baseUrl + this.searchValue
      window.open(url)
    },
  },
}
</script>

<style lang="scss">
#search {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-content: stretch;
  margin: 0 auto;
  height: 40vh;
  width: 40%;
  & > div {
    display: flex;
  }
}

.search-sources {
  span {
    margin-right: 0.5rem;
    padding: 0.4rem 0.6rem;
    color: #fff;
    font-size: 14px;
    line-height: 14px;
    border-radius: 2px;
    &:hover {
      filter: contrast(80%);
      transition: 0.3s;
    }
  }
}

.searchbox {
  padding-left: 1rem;
  height: 2.6rem;
  border-radius: 6px;
  background-color: #fff;
  border: 1px #ccc solid;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);

  input {
    flex: 7;
    border: none;
    font-size: 1rem;
  }

  button {
    flex: 1;
    i {
      margin-right: 0;
    }
  }
}

$sources-color: (
  baidu: #2932e1,
  bing: #0c8484,
  sougou: #ff6f17,
  google: #4285f4,
  npm: #ea4335,
);

$source-list: baidu bing sougou google npm;

@each $source in $source-list {
  .#{$source} {
    &:hover {
      border-color: map-get($sources-color, $source);
      box-shadow: 0 2px 4px map-get($sources-color, $source);
      transition: all 0.5s;
    }
    input {
      &:hover {
        border-color: map-get($sources-color, $source);
      }
    }
  }
}

@media screen and (max-width: 768px) {
  #search {
    width: 90%;
  }
}
</style>
components/Header.vue
<template>
  <header>
    <ul class="nav">
      <li @click="handleSelect('home')">
        <i class="fas fa-home"></i>
        <span>首页</span>
      </li>
      <li @click="handleSelect('tools')">
        <i class="fas fa-tools"></i>
        <span>工具</span>
      </li>
      <li @click="handleSelect('docs')">
        <i class="fas fa-file-alt"></i>
        <span>文档</span>
      </li>
      <li @click="handleSelect('community')">
        <i class="fas fa-comment-dots"></i>
        <span>社区</span>
      </li>
    </ul>
    <MobileMenu />
  </header>
</template>

<script>
import MobileMenu from './MobileMenu.vue'
import store from '../store'
import router from '../router'

export default {
  components: {
    MobileMenu,
  },

  setup() {
    const handleSelect = item => {
      store.commit('setAsideData', item)
      if (item === 'home') {
        router.replace({name: 'home'})
      } else {
        const actions = {
          tools: ['setMainData', '前端工具'],
          docs: ['setMainData', 'vue'],
          community: ['setMainData', '博客'],
        }
        store.commit(actions[item][0], actions[item][1])
        router.replace({name: 'cards'})
      }
    }

    return {
      handleSelect,
    }
  },
}
</script>

<style lang="scss">
header {
  display: flex;
  height: 60px;
  align-content: stretch;
  padding: 0 9.5rem;
}

.nav {
  display: flex;
  align-items: center;
  align-content: stretch;
  li {
    padding: 0.5rem 0.75rem;
    &:hover {
      background-color: #f3f1f1;
      & span {
        color: #3273dc;
      }
    }
  }
}

@media screen and (max-width: 768px) {
  header {
    padding: 0;
  }
}
</style>
components/MobileMenu.vue
<template>
  <section id="mobile-menu">
    <div id="navbarBurger" class="navbar-burger burger" data-target="navMenuMore" :class="{active}" @click="sideToggle">
      <span></span>
      <span></span>
      <span></span>
    </div>
  </section>
</template>

<script>
export default {
  data: () => ({
    active: false,
  }),
  methods: {
    sideToggle() {
      this.active = !this.active
      const classList = document.querySelectorAll('aside')[0].classList
      this.active ? classList.add('active') : classList.remove('active')
    },
  },
}
</script>

<style lang="scss">
#mobile-menu {
  display: none;
  position: absolute;
  right: 0;
  top: 0;
  z-index: 999999;
}

@media screen and (max-width: 768px) {
  #mobile-menu {
    display: block;
    .navbar-burger {
      position: relative;
      color: #835656;
      cursor: pointer;
      height: 60px;
      width: 60px;
      margin-left: auto;
      span {
        background-color: #333;
        display: block;
        height: 1px;
        left: calc(50% - 8px);
        position: absolute;
        transform-origin: center;
        transition-duration: 86ms;
        transition-property: background-color, opacity, transform;
        transition-timing-function: ease-out;
        width: 16px;
        &:nth-child(1) {
          top: calc(50% - 6px);
        }
        &:nth-child(2) {
          top: calc(50% - 1px);
        }
        &:nth-child(3) {
          top: calc(50% + 4px);
        }
      }
      &.active {
        span {
          &:nth-child(1) {
            transform: translateY(5px) rotate(45deg);
          }
          &:nth-child(2) {
            opacity: 0;
          }
          &:nth-child(3) {
            transform: translateY(-5px) rotate(-45deg);
          }
        }
      }
    }
  }
}
</style>

最后

一套流程下来,vite 给我的感觉就是“快”。对于 vue 周边, API 都是做了一些简化,如果你对 esm 有些了解,将更有利于组织项目,可读性相比 vue2.x 也更高。也有一些问题,限于篇幅,本文没有探讨。做项目还是上 vue2.x 及其周边。另外,我没找到 vue3 组件库。

上一篇:每日收获5


下一篇:Vue:使用vue-cli实现 todolist,自动添加一行,自动删除一行(三)