Vue3造轮子-过程

194 阅读4分钟

技术栈:

Vite + Vue 3 + Vue Router + TypeScript

vite 搭建官网

创建项目

yarn global add create-vite-app  //安装vite
cva <your-project-name>  //创建项目,cva是缩写
cd <your-project-name>
yarn   //安装依赖,相当于 yarn install
yarn dev  

1.2 初始化项目

  • index.html 项目首页
  • <div id="app"></div>容器
  • Vue 3 创建应用实例
//main.js
import { createApp } from 'vue'

const app = createApp({
  /* 根组件选项 */
})
  • 根组件

每个应用都需要一个“根组件”,其他组件将作为其子组件。

import { createApp } from 'vue'
// 从一个单文件组件中导入根组件
import App from './App.vue'

const app = createApp(App)
  • 挂载应用
  1. 应用实例必须在调用了 .mount() 方法后才会渲染出来。
<div id="app"></div>
createApp(App).mount('#app')
app.mount('#app')  //简写为
  1. 对比Vue 2 的写法
new Vue({
    router: router,
    store,
    render: h => h(App)
}).$mount('#app');
  • 安装 Vue 3 Snippets插件,快捷输入temp模板提示

Vue 3 的<template>支持多个根标签,Vue 2 不支持

//Frank.vue
<template>  
  <div>
      我的第一个组件
  <div>
</template>
  • 引入Frank组件
//App.vue
<template>
  <div>hi<div>
  <Frank />
</template>

<script>
import Frank from './components/Frank.vue'
export default {
    name:'App',
    component:{ Frank }
}

1.3 安装vue-router

npm info vue-router versions //查所有版本号
yarn add vue-router@4.0.0-beta.3
  • 初始化仓库

1.4 初始化vue-router

// router.ts
import { createWebHashHistory, createRouter } from "vue-router";
//引入 History型路由、创建路由
import Home from './components/Home.vue'
import Doc from './components/Doc.vue'
const history = createWebHashHistory();
const router = createRouter({
  history: history,
  routes: [
    { path: "/", component: Home },
    { path: "/xxx", component: Doc }
    ]
})
  • 解决不识别.vue后缀模块的报错,引入TS只能理解.ts文件,无法理解.vue文件:搜索Vue 3 can not find module,如果之后还是有这种出现下划线情况,就打开这个文件,再关闭,或者是在引入的组件中加入空的script标签,vscode就是这么智障。
  • 解决方法:创建shims-vue.d.ts文件,告诉ts理解.vue文件
//src/shims-vue.d.ts
declare module '*.vue'{
    import { ComponentOptions } from "vue"
    const componentOptions:ComponentOptions
    export default componentOptions
}
  • 使用router
//main.ts
const app = createApp(App);
app.use(router);
app.mount("#app");
//对比Vue2用法
import Vue from 'vue';
import VueRouter, {RouteConfig} from 'vue-router';

Vue.use(VueRouter);
const routes: Array<RouteConfig> = [
    {path:'/', redirect:'/money'},
    {path:'/money', component:Money},
    {path:'*', component: NotFound}
];
const router = new VueRouter({routes});
export default router;
  • 展示组件
//App.vue
<template>
  <div>导航栏 | <router-link to="/">Home<router-link> | <router-link to="/xxx">Doc<router-link><div>
  <hr/>
  <router-view />
</template>

<script>
export default {
    name:'App',
}
</script>

1.5 创建首页和文档页

  • 封装导航栏组件
//Topnav.vue
<template>
  <div class="topnav">
    <div class="logo">LOGO</div>
    <ul class="menu">
      <li>菜单1</li>
      <li>菜单2</li>
    </ul>
    <span class="toggleAside"></span>
  </div>
</template>
  • 写Home主页
<template>
  <div>
    <Topnav />
    <div class="banner">
      <h1>gulu-ui</h1>
      <h2> nb class UI </h2>
      <div class="actions">
        <a href="https://github.com">GitHub</a>
        <router-link to="/doc">开始</router-link>
      </div>
    </div>
  </div>
</template>
<script lang="ts">
import Topnav from "../components/Topnav.vue";
export default {
  components: { Topnav },
};
</script>
  • 写Doc文档页
<template>
  <div class="layout">
    <Topnav class="nav" />
    <div class="content">
      <aside>
        <h2>组件列表</h2>
        <ol>
          <li>
            <router-link to="/doc/switch">Switch 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/button">Button 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/dialog">Dialog 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/tabs">Tabs 组件</router-link>
          </li>
        </ol>
      </aside>
      <main>
        <router-view />
      </main>
    </div>
  </div>
</template>
<script lang="ts">
import Topnav from "../components/Topnav.vue";
export default {
  components: { Topnav }
};
</script>
  • 解决写sass时报错找不到模块:
  • 解决方法:安装sass,yarn add -D sass@1.26.10,使其安装在package.json文件的devDependencies中,重新更新配置yarn,再次yarn dev报错清除。

1.6 用<依赖注入>实现切换功能

需求:点击切换aside实现点一次显示,再点一次隐藏。用menuVisible = true/false控制是否显示

  • 用 ref() 定义响应式变量(创建任何值类型)
//`ref()` 将传入参数的值包装为一个带 `.value` 属性的 ref 对象:
import { ref } from 'vue'

const menuVisible = ref(false)
  • 组件间数据传递

父-子组件传递数据用 props
祖-子组件传递数据用依赖provide注入inject

  • 依赖provide
import { provide } from 'vue'

export default {
  setup() {
    provide("menuVisible", menuVisible)
  }
}
//第一个参数(注入名),类型:String /Symbol。后代组件用注入名来查找期望注入的值。
//第二个参数是提供的值,值可以是任意类型,包括响应式的状态(ref)
  • 注入inject
import { inject, Ref } from "vue";  //标注类型时自动引入ref
export default {
  setup() {
    const menuVisible = inject<Ref<boolean>>("menuVisible"); // 访问到变量并标注类型<Ref>,再标记数据类型<Ref<boolean>>
  },
};
  • 父组件标记menuVisible变量
//App.vue
<template>
  <router-view />
</template>

<script lang="ts">
import { ref, provide } from "vue";  // 安装`auto import`插件,自动引入
import { router } from "./router";
export default {
  name: "App",
  setup() {
    const menuVisible = ref(false) //默认是看不见
    provide("menuVisible", menuVisible); // 标记变量可被子组件访问
  },
};
</script>
  • 子组件访问到变量
//Topnav.vue
<template>
  <div class="topnav">
    <div class="logo">LOGO</div>
    <ul class="menu">
      <li>菜单1</li>
      <li>菜单2</li>
    </ul>
    <span class="toggleAside"></span>
  </div>
</template>
<script lang="ts">
import { inject, Ref } from "vue";  //标注类型时自动引入ref
export default {
  setup() {
    const menuVisible = inject<Ref<boolean>>("menuVisible"); // 访问到变量并标注类型<Ref>,再标记数据类型<Ref<boolean>>
  },
};
</script>
  • 子组件修改变量,点击元素时,把变量boolean值取反
//Topnav.vue
<template>
  <div class="topnav">
    <div class="logo">LOGO</div>
    <ul class="menu">
      <li>菜单1</li>
      <li>菜单2</li>
    </ul>
    <span class="toggleAside" @click="toggleMenu"></span>
  </div>
</template>
<script lang="ts">
import { inject, Ref } from "vue";  //标注类型时自动引入ref
export default {
  setup() {
    const menuVisible = inject<Ref<boolean>>("menuVisible"); // 访问到变量并标注类型<Ref>,再标记数据类型<Ref<boolean>>
    const toggleMenu = () => {
      menuVisible.value = !menuVisible.value;
    };
    return { toggleMenu };
  },
};
</script>
//Doc.vue
<template>
  <div class="layout">
    <Topnav class="nav" />
    <div class="content">
      <aside v-if="menuVisible">  //menuVisible 变化
        <h2>组件列表</h2>
        <ol>
          <li>
            <router-link to="/doc/switch">Switch 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/button">Button 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/dialog">Dialog 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/tabs">Tabs 组件</router-link>
          </li>
        </ol>
      </aside>
      <main>
        <router-view />
      </main>
    </div>
  </div>
</template>
<script lang="ts">
import Topnav from "../components/Topnav.vue";
import { inject, Ref } from "vue";
export default {
  components: { Topnav },
  setup() {
    const menuVisible = inject<Ref<boolean>>("menuVisible"); // get
    return { menuVisible };
  },
};
</script>

1.7 适配手机页面上的切换按钮

  • 页面宽度低于500px时,隐藏菜单,并在左上角出现切换按钮
//Topnav.vue
@media(max-width:500px){
 > .menu{display:none}
 > .logo{margin: 0 auto}
 > .toggleAside{display:inline-block}
}
  • 调整Doc文档页面
//Doc.vue
...
@media(max-width:500px){
 position:fixed;
 top:0;
 left:0;
 padding-top:70px;
}
  • PC端设置menuVisible默认值为true
<template>
  <router-view />
</template>

<script lang="ts">
import { ref, provide } from "vue";
import { router } from "./router";
export default {
  name: "App",
  setup() {
    const width = document.documentElement.clientWidth;
    const menuVisible = ref(width <= 500 ? false : true);  //小于500px为手机
    provide("menuVisible", menuVisible); // set
    router.afterEach(() => {
      if (width <= 500) {
        menuVisible.value = false;
      }
    });
  },
};
</script>

1.8 路由间切换,实现嵌套路由

  • 加入Vue Router时,将组件映射到路由上
//App.vue
<template>
  <router-view />  //路由出口,路由匹配到的组件将渲染到这里
</template>
//Doc.vue
 <router-link to="/doc/switch">Switch 组件</router-link>
//导航,传递 to 指定链接 
  • 路由配置
//router.ts
...
const history = createWebHashHistory();
export const router = createRouter({
  history: history,
  routes: [
    { path: "/", component: Home },
    {
      path: "/doc",
      component: Doc,
      children: [
        { path: "", component: DocDemo },    //二级组件加入根路由
        { path: "switch", component: SwitchDemo },
        { path: "button", component: ButtonDemo },
        { path: "dialog", component: DialogDemo },
        { path: "tabs", component: TabsDemo },
      ],
    },
  ],
});
  • 对应的路由展示:
//Doc.vue
<template>
  <div class="layout">
    <Topnav class="nav" />
    <div class="content">
      <aside v-if="menuVisible">
        <h2>组件列表</h2>
        <ol>
          <li>
            <router-link to="/doc/switch">Switch 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/button">Button 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/dialog">Dialog 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/tabs">Tabs 组件</router-link>
          </li>
        </ol>
      </aside>
      <main>
        <router-view />
      </main>
    </div>
  </div>
</template>
  • 实现手机端点击路由组件后,自动消除组件列表
//App.vue
<template>
  <router-view />
</template>

<script lang="ts">
import { ref, provide } from "vue";
import { router } from "./router";
export default {
  name: "App",
  setup() {
    const width = document.documentElement.clientWidth;
    const menuVisible = ref(width <= 500 ? false : true);
    provide("menuVisible", menuVisible); // set
    router.afterEach(() => {   //设置手机端自动消除组件列表
      if (width <= 500) {
        menuVisible.value = false;
      }
    });
  },
};
</script>

2. Switch开关组件

参考网站: AntDAntD VueBulma

主要需求:实现切换、动画,外部控制初始及更新状态,事件响应及V-model简化,样式优化

Switch 开关组件怎么用

//不加冒号 value为字符串"true" 显示为开 
<Switch value="true"> 
    
//加冒号 value为变量-布尔值true 显示为开        
<Switch :value="true"> 

2.1 初始化

//src/lib/Switch.vue
<template>
  <div>Switch组件</div>
</template>
//src/components/SwitchDemo.vue
<template>
  <div>
    <Switch />   //嵌套路由
  </div>
</template>
<script lang="ts">
import Switch from "../lib/Switch.vue";
export default {
  components: { Switch },
};
</script>

2.2 实现切换、动画

  • 写开关样式,并初步实现hover之后,就向右滚动,初步实现开关切换;
  • 添加点击事件,初始默认为false,点击后状态为true;

页面yarn dev时为空白,有可能是代码拼写错误导致

//src/lib/Switch.vue
<template>
  <!-- 若x为ture 则checked选中 -->
  <button @click="toggle" :class="{ checked: checked }"><span></span></button>
</template>
<script lang="ts">
import { ref } from "vue";

export default {
  setup() {    //初始化
    const checked = ref(false);
    const toggle = () => {
      checked.value = !checked.value;  //单击按钮触发,value取反
    };
    return { checked, toggle };
  },
};
</script>
...
button {
  ...
  background: grey;  //初始默认为灰色
}
button.checked {
  background: blue;   //选中打开时为蓝色
}
button.checked > span {
  left: calc(100% - #{$h2} - 2px);
}

2.3 添加value属性和input事件

  • 外界无法知道当前状态是开还是关,以及初始状态
  • SwitchDemo组件(父组件)控制Switch组件(子组件)的开关,构造一个value属性值为变量y和input事件等于y=$event
  • 父组件通过添加value属性和input事件,两者的作用是:value属性控制每一次的状态,都是在触发事件之后更新一次,switch再次渲染一次;input事件可以通过$event拿到最新的值,
//SwitchDemo
<template>
  <div>
    <Switch :value="y" @input="y = $event" />
  </div>
</template>
<script lang="ts">
import { ref } from "vue";
import Switch from "../lib/Switch.vue";
export default {
  components: { Switch },
  setup() {
    const y = ref(true);
    return { y };
  },
};
  • 子组件用props接收value值,用context.emit('input')发出input事件

2.4 补充说明

  • $event的值是emit的第二个参数,emit(事件名, 事件参数)
  • Vue 3官方命名把context.emit('input')命名为:context.emit('update:value')props.value对应
  • v-model简写:
 <Switch :value="y" @input="y = $event" />
 <Switch v-model:value="y" />    //v-model简写

3. 制作Tabs组件

3.1 如何检查子组件的类型

  • 检查context.slots.default()数组

先把context的内容log出来,结果中有slots属性:

setup(props, context) {
    console.log({ ...context });
  },

00.png

再把context.slots的内容log出来,结果中有default属性,经查需要打印default()函数:

console.log({ ...context.slots.default() });

02.png

打印出的函数内容,有01属性,就是对应的tab

  • 通过 const defaults = context.slots.default()的结果获取外面传入的子内容,可以用<component :is="defaults[0]" />展示出所有子内容,再去看每个子内容的标签是什么
console.log(defaults[0].type === Tab);
//true  
  • 意味着defaults[0].type就是Tab.vue,也就是所有的.vue文件最终都被Vue转换为对象。
setup(props, context) {
    const defaults = context.slots.default();
    defaults.forEach((tag) => {
      if (tag.type !== Tab) {
        throw new Error("Tabs 子标签必须为 Tab");
      }   //防御型编程(若TabsDemo中输入的是div就报错)
    });
// forEach遍历数组,将遍历到的元素传递给回调函数,
//第一个参数tag是遍历的数组内容,
//省略了第二个参数是对应的数组索引
//第三个参数是数组本身    

详说数组遍历:

  1. forEach( )方法不会返回执行结果,而是undefined,被调用时,不会改变原数组
  2. map( )方法会分配内存空间存储新数组并返回,不修改调用他的原数组本身
  3. jQuery里的$each(arr|obj, function(k,v))遍历数组和对象

3.2 如何渲染嵌套的组件

<template>
  <div>
    Tabs 组件
    <component :is="defaults[0]" />
    <component :is="defaults[1]" />
  </div>
</template>

改写上面的写法:

<template>
  <div>
  // v-for 和 key 得成对出现,不能只写一个
    <div v-for="(t, index) in titles" :key="index">{{ t }}</div>
    <component v-for="(c, index) in defaults" :is="c" :key="index" />
  </div>
</template>

<script lang="ts">
import Tab from "./Tab.vue";
export default {
  setup(props, context) {
    const defaults = context.slots.default();
    defaults.forEach((tag) => {
      if (tag.type !== Tab) {
        throw new Error("Tabs 子标签必须为 Tab");
      }
    });
    const titles = defaults.map((tag) => {
      return tag.props.title;
    });
    return {
      defaults,
      titles,
    };
  },
};
</script>

4.3 显示被选中的导航(切换标签页)

selected标记被选中的标签页

  • selected用index表示,不推荐
  • selected用name表示,不方便
  • selected用title表示,有漏洞
  • title实现切换导航:
//TabsDemo.vue
<Tabs selected="导航1">
    <Tab title="导航1" selected>内容1</Tab>
    <Tab title="导航2">内容2</Tab>
  </Tabs>
//Tabs.vue
<div class="gulu-tabs-nav-item"
     :class="{ selected: t === selected }"
     v-for="(t, index) in titles"
     :key="index"> {{ t }}</div>
...     
props: {
    selected: {
      type: String,
    },
  },     
  • <component :is="current" :key="selected" />实现切换内容:
//Tabs.vue
<div class="gulu-tabs-content">
      <component :is="current" :key="current.props.title" />
</div>
  
<script lang="ts">
import { computed } from "vue";
export default {
    setup(){
        const current = computed(() => {
          return defaults.find((tag) => tag.props.title === props.selected);
    });
        return {current}
    }
}

4.4 动态设置导航条的宽度

  • 先加一个div,然后对应添加样式
<div class="gulu-tabs-nav-indicator"></div>

10110.png

  • 解决导航下划线宽度过长,使下划线的宽度与导航1(title)保持一致,用refv-for结合使用
<div class="gulu-tabs-nav-item" :class="{ selected: t === selected }" v-for="(t, index) in titles" 
    :ref="(el) => {if (el) navItems[index] = el;}" @click="select(t) :key="index">
        {{ t }}
</div>
<div class="gulu-tabs-nav-indicator" ref="indicator"></div>
      

<script lang="ts">
import { computed, onMounted, ref } from "vue";
export default {
    setup() {
        const divs = navItems.value;
      const result = divs.filter((div) =>
        div.classList.contains("selected")
      )[0];
      const { width } = result.getBoundingClientRect();

      indicator.value.style.width = width + "px";
    });
    return {navItems,indicator}
}
</script>

4.5 动态设置导航条的位置

<div class="gulu-tabs-nav" ref="container">

 const container = ref<HTMLDivElement>(null);
return {container}

  • 用CSS实现导航条滑动效果
.gulu-tabs-nav-indicator{transition: all 250ms;}

5. 官网装修

  • 从设计站点的模板中取主题颜色:

Dribbble.comThemeForest.net

  • 生成渐变色效果CSS:

css gradient

  • 应用到项目中,注意删掉topnavbanner中的颜色样式
//Home.vue
<div class="topnavAndBanner">
    <Topnav />
    <div class="banner">
...
.topnavAndBanner {
  background: linear-gradient(
    145deg,
    rgba(227, 255, 253, 1) 0%,
    rgba(183, 233, 230, 1) 100%
  );
}
  • 优化按钮、文字、链接的样式
//index.scss
a {
  text-decoration: none;  //清除文本修饰
  color: inherit;
  &:hover {
    text-decoration: underline;
    cursor: pointer;
  }
}
body {
  font-size: 16px;
  line-height: 1.5;
  color: #1d2c40;
}  
//Home.vue
$green: #02bcb0;
$border-radius: 4px;
$color: #007974;
.banner{
    color: $color;
    > .actions 
      padding: 8px 0;
      a {
      margin: 0 8px;
      background: $green;
      color: white;
      display: inline-block;
      padding: 8px 24px;
      border-radius: $border-radius;
       &:hover {
        text-decoration: none;
       }
      }
}

如果之前预选的颜色放到页面中后,效果不达标,还可以去控制台重新选效果,再把满意的颜色重填到代码中。

  • 添加icon或logo

素材在iconfont.cn找,选中号icon后,添加入库,再添加至项目,可修改icon名称,选择symbol选项卡,查看在线链接,生成代码:

2235.png

icon引入到源码中,有两种方式,一种是直接拷贝项目下面生成的symbol代码:

//index.html
<head>
  <meta charset="UTF-8">
  <link rel="icon" href="/favicon.ico" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vite App</title>
  <script src="//at.alicdn.com/t/c/font_3670626_ci4r8085hdb.js"></script>  //symbol代码
</head>
  • 使用icon,加入通用css代码

在iconfont项目中,找使用帮助,搜索Symbol引入

//index.scss
.icon {
  width: 1em; height: 1em;
  vertical-align: -0.15em;
  fill: currentColor;
  overflow: hidden;
}
  • 页面中使用icon,挑选相应图标并获取类名(标签中的类名要与iconfont项目中的类名一致),应用于页面:
//topnav.vue
<div class="logo">
      <svg class="icon">
        <use xlink:href="#icon-gears-ui"></use>
      </svg>
    </div>
...
.topnav {
    > .logo > svg {
      width: 32px;
      height: 32px;
    }
}
//Home.vue
<div class="features">
      <svg class="icon">
        <use xlink:href="#icon-Vue"></use>
      </svg>
      <svg class="icon">
        <use xlink:href="#icon-ts"></use>
      </svg>
      <svg class="icon">
        <use xlink:href="#icon-lights"></use>
      </svg>
    </div>
...
.features {
  > svg {
    width: 64px;
    height: 64px;
  }
}
  • icon引入到源码中,第二种方法是把### symbol代码的JS文件复制到本地:在浏览器中打开链接,将内容粘贴到lib/svg.js文件中,再从main.ts中引入这个文件import './lib/svg.js',也是可以的。

  • 使用 clip-path 画圆弧

.topnavAndBanner {
  background: linear-gradient(
    145deg,
    rgba(227, 255, 253, 1) 0%,
    rgba(183, 233, 230, 1) 100%
  );
  clip-path: ellipse(80% 60% at 50% 40%);
}
  • 响应式页面

先写手机样式+@media(min-width:600px)+@media(min-width:800px)+(min-width:1200px)

6. 部署发布官网

就是把dist目录(存放所有代码打包之后的结果)上传到网上

6.1 本地测试

  • 删除项目中的dist文件夹
  • 打开.gitignore文件
node_modules
.DS_Store
dist
/dist
*.local
/.history
  • yarn build得到新的dist目录

77.png

  • 本地测试网站是否成功运行
yarn add http-server //本地测试网站是否可以正常使用 ^14.1.1
http-server dist -c-1  //默认打开8080端口页面

6.2 部署到GitHub

  • GitHub默认会吞掉带下划线的文件_
  • 新建仓库 gulu-ui-website
  • 关闭自动翻译功能,确保链接为SSH

1255.png

cd dist
git init
git add .
git commit -m "2nd commit"
git branch -M main
git remote add origin git@github.com:jianlong5296/gulu-ui-14.git
git push -u origin main
git push -f -u origin main  //更新时覆盖之前的

6.3 打包库文件 ,自行配置rollup

    1. 创建 lib/index.ts,将所有需要导出的东西导出
import Switch from './Switch.vue'
export {Switch}
//合并简写为如下:
export {default as Switch} from './Switch.vue'
    1. 告诉rollup怎么打包
$ yarn add --dev rollup-plugin-esbuild rollup-plugin-vue rollup-plugin-scss sass rollup-plugin-terser

目前的bug,手机端,Switch开关组件index层比菜单高

参考

jirengu.com