# Vue 进阶

# 一、Vue 实例生命周期

# 1.1 这个定时器要放哪

我们经常使用定时器控制页面展示效果,那么这个定时器应该放到哪里呢?我们可以看下面这个例子。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  </head>
  <body>
    <div id="root">
      <h2 :style="{opacity}">你好,{{name}}</h2>
    </div>
    <script type="text/javascript">
      Vue.config.productionTip = false;
      const vm = new Vue({
        el: "#root",
        data: { name: "张三", opacity: 1 },
      });
      setInterval(() => {
        vm.opacity -= 0.1;
        if (vm.opacity <= 0) vm.opacity = 1;
      }, 100);
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

上面例子里的定时器放在了 vm 实例创建完之后,这会有什么问题呢?定时器与 vm 实例在代码层面割裂开的,但是功能是有联系的,这符合一般的开发规范。那我们得考虑将定时器放到 vm 实例里,那放在methods里吗?methods里的方法一般是充当事件回调函数的,如果你非要新建一个方法包裹setInterval定时器,那还得对应在模板里主动调用这个新方法,代码如下,运行效果不符合预期。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  </head>
  <body>
    <div id="root">
      <h2 :style="{opacity}">你好,{{name}}</h2>
      <h2>{{change()}}</h2>
    </div>
    <script type="text/javascript">
      Vue.config.productionTip = false;
      const vm = new Vue({
        el: "#root",
        data: {
          name: "张三",
          opacity: 1,
        },
        methods: {
          change() {
            setInterval(() => {
              console.log("定时器");
              this.opacity -= 0.1;
              if (this.opacity <= 0) this.opacity = 1;
            }, 100);
          },
        },
      });
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

上面例子的运行效果不符合预期,这是因为定时器里做了data存储数据的修改(vm.opacity = xxx),会导致模板一直重新解析,那就一直会调用change

我们的期望是只让change始终值只调用一次,在真实 DOM第一次被放到页面时调用它,在之后的模板重新解析时不再调用它,那这就不得不用到 Vue 的声明周期函数mounted就是 Vue 生命周期函数中的一个,在 Vue 完成模板解析并真实 DOM 元素放入页面后回调mounted,也就说mounted只在挂载后调用一次,在后面每次更新不会再被调用了

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  </head>
  <body>
    <div id="root">
      <h2 :style="{opacity}">你好,{{name}}</h2>
    </div>
    <script type="text/javascript">
      Vue.config.productionTip = false;
      const vm = new Vue({
        el: "#root",
        data: {
          name: "张三",
          opacity: 1,
        },
        // mounted生命周期函数,在Vue完成模板解析并真实DOM元素放入页面后被调用(挂载后)
        mounted() {
          setInterval(() => {
            console.log("定时器");
            this.opacity -= 0.1;
            if (this.opacity <= 0) this.opacity = 1;
          }, 100);
        },
      });
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

总结拓展

  1. 生命周期函数:Vue 在关键时刻帮我们调用的一些特殊名称的函数。
  2. 生命周期函数又名生命周期回调函数生命周期钩子
  3. 生命周期函数的方法名是固定的不可更改的,方法体内容根据开发需求自行修改。
  4. 生命周期函数中的this指向是vm组件实例对象

# 1.2 挂载流程

Vue的挂载流程

挂载流程

  1. Init Events & Lifecycle阶段:规定 Vue 的生命周期函数有多少个、在什么时候调用,还规定了事件与事件修饰符的怎么运行。该阶段还未进行数据处理。该阶段紧跟其后的一个生命周期函数beforeCreate(),该生命周期函数中还无法通过 vm 访问到data 中的数据methods 中的方法

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>Document</title>
        <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
      </head>
      <body>
        <div id="root">
          <h2>你好,{{name}}</h2>
          <button @click="change">点击我</button>
        </div>
        <script type="text/javascript">
          Vue.config.productionTip = false;
          const vm = new Vue({
            el: "#root",
            data: {
              name: "张三",
            },
            // beforeCreate生命钩子:无法通过vm访问到data中的数据、methods中的方法。
            beforeCreate() {
              console.log("尝试访问data数据", this.name, this._data);
              console.log("尝试访问methods方法", this.add);
              debugger;
            },
            methods: {
              change() {
                this.name = "李四";
              },
            },
          });
        </script>
      </body>
    </html>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
  2. Init injections & reactivity阶段:处理了依赖注入等,并对存储数据做了数据监视数据代理。该阶段紧跟其后的一个生命周期函数created(),该生命周期函数中可以通过 vm 访问到 data 中的数据、methods 中的方法。

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>Document</title>
        <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
      </head>
      <body>
        <div id="root">
          <h2>你好,{{name}}</h2>
          <button @click="change">点击我</button>
        </div>
        <script type="text/javascript">
          Vue.config.productionTip = false;
          const vm = new Vue({
            el: "#root",
            data: {
              name: "张三",
            },
            // created生命钩子:可以通过vm访问到data中的数据、methods中的方法。
            created() {
              console.log("尝试访问data数据", this.name, this._data);
              console.log("尝试访问methods方法", this.add);
              debugger;
            },
            methods: {
              change() {
                this.name = "李四";
              },
            },
          });
        </script>
      </body>
    </html>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
  3. 模板解析阶段:开始解析模板生成虚拟 DOM存储在内存中,还未生成对应的真实 DOM。该阶段紧跟其后的一个生命周期函数beforeMount(),在此生命钩子中对应页面的展示还是模板内容,虽然虚拟 DOM 生成了,但真实 DOM还未加到页面中,所以不要在此生命钩子中操作真实 DOM。
    模板解析了但未生成真实DOM

  4. Create vm.$el and replace "el" with it阶段:将虚拟 DOM 转换成真实 DOM,并将真实 DOM 存储在vm.$el,然后将真实 DOM插入了页面。该阶段紧跟其后的一个生命周期函数mounted(),在此生命钩子中对应页面的展示已经是真实 DOM 了,可以进行真实 DOM 操作(不建议),也可以开启定时器发送网络请求订阅消息绑定自定义事件初始化操作
    生成了真实DOM并挂到了页面

# 1.3 更新流程

Vue的挂载流程

更新流程比较简单,当 data 存储数据改变时就去更新页面。Virtual DOM re-renderand patch阶段:生成新的虚拟 DOM,再与旧的虚拟 DOM 进行比较,根据比较结果更新页面。该阶段有个生命周期函数beforeUpdate(),data 存储数据是新的,但页面还是旧的。该阶段有个生命周期函数updated(),data 存储数据是新的,页面也是新的。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  </head>
  <body>
    <div id="root">
      <h2>你好,{{name}}</h2>
      <button @click="change">点击我</button>
    </div>
    <script type="text/javascript">
      Vue.config.productionTip = false;
      const vm = new Vue({
        el: "#root",
        data: {
          name: "张三",
        },
        // beforeUpdate生命钩子:数据是新的,但页面是旧的,即页面尚未和数据保持同步。
        beforeUpdate() {
          console.log("data数据是新的:", this.name);
          console.log("页面是旧的", document.getElementsByTagName("h2")[0].innerText);
          debugger;
        },
        // updated生命钩子:数据是新的,页面也是新的,即页面和数据保持同步。
        updated() {
          console.log("data数据是新的:", this.name);
          console.log("页面也是新的", document.getElementsByTagName("h2")[0].innerText);
          debugger;
        },
        methods: {
          change() {
            this.name = "李四";
          },
        },
      });
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

# 1.4 销毁流程

Vue的挂载流程

销毁流程比较简单,当vm被销毁时(调用vm.$destroy())就执行销毁操作。Teardown watchers, child components and event listeners阶段,移除数据监听、子组件和事件监听器(不影响原生事件),但是页面的 DOM显示还是正常的。该阶段有个生命周期函数beforeDestroy(),此时还能使用 vm 中所有的 data、methods、指令等(但改动 data 存储数据是不会触发更新,因为要销毁了),但主要是为了进行关闭定时器取消订阅消息解绑自定义事件收尾操作。该阶段有个生命周期函数destroyed()几乎不在该生命周期函数里处理事情。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  </head>
  <body>
    <div id="root">
      <h2>你好,{{name}}</h2>
      <h2>数值,{{num}}</h2>
      <button @click="change">点击我,改变name,并销毁vm</button>
    </div>
    <script type="text/javascript">
      Vue.config.productionTip = false;
      const vm = new Vue({
        el: "#root",
        data: {
          name: "张三",
          num: 1,
        },
        // beforeDestroy生命钩子:vm中所有的data、methods、指令等,都处于可用状态,马上要执行销毁过程,
        // 一般在此阶段:关闭定时器、取消订阅消息、解绑自定义事件等收尾操作。
        beforeDestroy() {
          console.log("尝试访问data数据", this.name, this._data);
          console.log("尝试访问methods方法", this.add);
          this.num = this.num + 1;
          console.log("让num+1:", this.num, "再观察页面是否更新");
          debugger;
        },
        // destroyed生命钩子:几乎不在该生命周期函数里处理事情
        destroyed() {},
        methods: {
          change() {
            this.name = "李四";
            // 销毁vm
            // this.$destroy();
          },
        },
      });
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

注意:最好不要使用vm.$destroy(),最好使用v-if

# 1.5 整个生命周期图示

总共有 4 对生命周期函数:

  • beforeCreate()created(),数据处理前后
  • beforeMount()mounted(),真实 DOM 插入页面前后
  • beforeUpdate()updated(),虚拟和真实 DOM 更新前后
  • beforeDestroy()destroyed(),vm 或组件实例销毁前后

Vue生命周期


总结拓展

  1. 常用的生命周期钩子:
    1. mounted():发送 ajax 请求、启动定时器、绑定自定义事件、订阅消息等初始化操作
    2. beforeDestroyed():清除定时器、解绑自定义事件、取消订阅消息等收尾操作
  2. 关于销毁 Vue 实例:
    1. 销毁后借助 Vue 开发者工具看不到任何信息。
    2. 销毁后自定义事件会失效,但原生 DOM 事件依然有效。
    3. 一般不会在beforeDestroyed()里操作数据,即使操作了数据,也不会触发重新渲染。

# 二、Vue 组件化编程

# 2.1 什么是组件化编程

传统方式来开发 web 项目,会出现多个 html、多个 js、多个 css 文件,它们之间的关系会很混乱,维护起来非常不方便。一旦出现功能和展示几乎相同的部分,就会难以复用。当然,随着前端技术的进步可以解决一部分问题,js 可以使用es6 模块(拆分 js,以模块形式导入导出),css 也能使用css in js等技术(以模块导入)。

传统方式编写应用

现代前端技术,UI 框架基本推荐使用组件化编程的方式。首先,“组件”的定义是,实现应用中局部功能代码资源集合

那么组件化编程是什么意思呢?是将页面拆分成一个个组件,单个组件会封装所需要的代码资源(局部功能)。虽然组件是一个独立的个体,但是多个组件是可以进行互相交流的,将开发好了的组件组合在一起,形成一个功能完善的页面(完整功能),这就是组件化编程。当然,组件的高度封装具有独立性,是可以被拿到其他地方进行复用的。

使用组件方式编写应用

# 2.2 如何运用组件

在这几节里为了代码演示,暂时使用非单文件组件,至于单文件组件在实际开发中使用的更多,会在后面讲解。非单文件组件的意思是一个文件包含了多个组件,那单文件组件自然就是一个文件只包含了一个组件。

在开发中到底怎么运用组件呢?分为三步:1. 创建组件;2. 注册组件;3. 使用组件。

  • 创建组件

    1. 使用Vue.extend(xxx)进行创建组件,其中 xxx 是一个配置对象,该配置对象和Vue 实例化时传入的配置对象几乎相同的(比如配置对象中的el只在 Vue 实例化时才能用)。

    2. 组件的配置对象的 data,一定得使用函数形式。假设组件 A会在组件 B组件 C里被调用(被复用),组件 B 去修改了A 的 data 数据,因为 data 是对象的缘故,在组件 C 中看到自己调用的 A 的 data 数据也变了,这就导致各个调用处共用了一套数据。那就需要使用 data 的函数形式,data 函数return一个新对象组件 B使用 A 的 data 数据是独一份的,组件 C使用 A 的 data 数据也是独一份的。

      // data返回一个对象,各自调用处用的data数据就不会是同一个对象了
      function data() {
        return { a: 1, b: 2 };
      }
      const x1 = data();
      const x2 = data();
      x1.a = 3; // 影响不到x2的a
      console.log("x2.a:", x2.a);
      
      1
      2
      3
      4
      5
      6
      7
      8
    3. 可以将组件的模板写在组件的配置对象里,使用的是template,它与data平级,template是一个字符串

      Vue.config.productionTip = false;
      const school = Vue.extend({
        template: `
              <div>
                  <h2>学校:{{schoolName}}</h2>
                  <h2>地址:{{schoolAddress}}</h2>
              </div>`,
        data() {
          return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
        },
      });
      const person = Vue.extend({
        template: `
              <div>
                  <h2>姓名:{{name}}</h2>
                  <h2>年龄:{{age}}</h2>
              </div>`,
        data() {
          return { name: "张三", age: 18 };
        },
      });
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
  • 注册组件

    1. 要在调用处注册将要使用的组件。需要在调用处的配置对象中使用一个新的配置componentscomponentsmethods平级。组件注册在components对象里,属性名也就是组件名(自己取的,将来会在模板里使用),属性值就是组件引用(组件创建后留下的组件引用)。

      Vue.config.productionTip = false;
      const school = Vue.extend({
        template: `
              <div>
                  <h2>学校:{{schoolName}}</h2>
                  <h2>地址:{{schoolAddress}}</h2>
              </div>`,
        data() {
          return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
        },
      });
      const person = Vue.extend({
        template: `
              <div>
                  <h2>姓名:{{name}}</h2>
                  <h2>年龄:{{age}}</h2>
              </div>`,
        data() {
          return { name: "张三", age: 18 };
        },
      });
      const vm = new Vue({
        el: "#root",
        // 局部注册组件(在调用处的配置对象的components里),属性名和组件引用的名相同,就用简写
        components: {
          school,
          person,
        },
      });
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
    2. 上一点是局部注册,还有一个全局注册,使用得是Vue.component('xxx', xxx),第一个参数是组件名(自己取的,将来会在调用处的模板里使用),第二个参数是组件引用(组件创建后留下的组件引用)。

      Vue.config.productionTip = false;
      const school = Vue.extend({
        template: `
              <div>
                  <h2>学校:{{schoolName}}</h2>
                  <h2>地址:{{schoolAddress}}</h2>
              </div>`,
        data() {
          return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
        },
      });
      const person = Vue.extend({
        template: `
              <div>
                  <h2>姓名:{{name}}</h2>
                  <h2>年龄:{{age}}</h2>
              </div>`,
        data() {
          return { name: "张三", age: 18 };
        },
      });
      // 全局注册组件,school和person能被所有组件使用,这种情况用的少
      Vue.component("school", school);
      Vue.component("person", person);
      const vm = new Vue({
        el: "#root",
      });
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
  • 使用组件:在模板中使用组件的时候,先找到组件在配置对象中components里对应的属性名,再将这个属性名使用<>包裹成标签就可以到模板里使用了。

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>Document</title>
        <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
      </head>
      <body>
        <div id="root">
          <!-- 使用组件,编写组件标签 -->
          <school></school>
          <hr />
          <person></person>
        </div>
        <script type="text/javascript">
          Vue.config.productionTip = false;
          // 创建组件
          const school = Vue.extend({
            template: `
                    <div>
                        <h2>学校:{{schoolName}}</h2>
                        <h2>地址:{{schoolAddress}}</h2>
                    </div>`,
            data() {
              return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
            },
          });
          const person = Vue.extend({
            template: `
                    <div>
                        <h2>姓名:{{name}}</h2>
                        <h2>年龄:{{age}}</h2>
                    </div>`,
            data() {
              return { name: "张三", age: 18 };
            },
          });
          const vm = new Vue({
            el: "#root",
            // 注册组件
            components: {
              school,
              person,
            },
          });
        </script>
      </body>
    </html>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48

总结拓展

  1. Vue 中使用组件的三大步骤:
    1. 定义组件(创建组件)
    2. 注册组件
    3. 使用组件(编写组件标签)
  2. 如何定义一个组件:
    1. 使用Vue.extend(options)创建(注意extend没有带s),其中 options 和new Vue(options)时传入的那个 options 几乎一样,但也有区别:
      • el不要写,为什么?——最终所有的组件都要经过 Vue 的管理,由 vm 中的 el 决定服务于哪个容器。
      • data必须写成函数,为什么?——避免组件被复用时,数据存在引用关系(被共用了)。
    2. 备注:使用template可以配置组件结构(模板)。
  3. 如何注册组件:
    1. 局部注册:new Vue(options)时,options 的components配置。
    2. 全局注册:Vue.component('组件名', 组件),注意component没有带s
  4. 编写组件标签:<school></school>

# 2.3 组件的几个注意点

在上面一小节里的注册组件里,components的属性名也就是组件名,这个是“自己取的”。这个组件名将来会被用到模板里去使用,它有两种命名情况:

  • 一个单词组成:1) 可以字母全部小写,例如school;2) 可以只有首字母大写,例如School
  • 多个单词组成:2) 使用短横线-连接,例如my-school;2) 让每个单词的首字母大写,例如MySchool。在一般的 html 页面,MySchool其实会出现问题,但是在 Vue脚手架里却是正常的。
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  </head>
  <body>
    <div id="root">
      <!-- 使用组件,编写组件标签 -->
      <my-school></my-school>
      <hr />
      <person></person>
    </div>
    <script type="text/javascript">
      Vue.config.productionTip = false;
      // 创建组件
      const sch = Vue.extend({
        template: `
                    <div>
                        <h2>学校:{{schoolName}}</h2>
                        <h2>地址:{{schoolAddress}}</h2>
                    </div>`,
        data() {
          return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
        },
      });
      const person = Vue.extend({
        template: `
                    <div>
                        <h2>姓名:{{name}}</h2>
                        <h2>年龄:{{age}}</h2>
                    </div>`,
        data() {
          return { name: "张三", age: 18 };
        },
      });
      const vm = new Vue({
        el: "#root",
        // 注册组件
        components: {
          // 属性名,是多个单词,那用短横线连接`my-school`;也可每个单词首字母大写`MySchool`,但只能出现在脚手架里
          "my-school": sch, // sch是创建组件后留下的组件引用名,`my-school`是注册时的组件名,注意区分两者
          // 属性名,是单个单词,可以全部小写`person`,也可以只有首字母大写`Person`
          Person: person,
        },
      });
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

模板里使用组件,可以是<person></person>也可以是<person/>。第二种的<person/>非脚手架环境中会出现渲染问题,它后面的组件不会被渲染。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  </head>
  <body>
    <div id="root">
      <!-- 使用组件,在非脚手架环境中,只渲染了第一个person组件 -->
      <school />
      <school />
      <school />
    </div>
    <script type="text/javascript">
      Vue.config.productionTip = false;
      // 创建组件
      const school = Vue.extend({
        template: `
                <div>
                    <h2>学校:{{schoolName}}</h2>
                    <h2>地址:{{schoolAddress}}</h2>
                </div>`,
        data() {
          return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
        },
      });
      const vm = new Vue({
        el: "#root",
        // 注册组件
        components: { school },
      });
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

创建组件时,可以将Vue.extend省略,直接将options赋给引用。表面省略了实际底层还是调用了Vue.extend

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  </head>
  <body>
    <div id="root">
      <!-- 使用组件 -->
      <school></school>
    </div>
    <script type="text/javascript">
      Vue.config.productionTip = false;
      // 创建组件,省略了Vue.extend(),实际底层调用了Vue.extend()
      const school = {
        template: `
                <div>
                    <h2>学校:{{schoolName}}</h2>
                    <h2>地址:{{schoolAddress}}</h2>
                </div>`,
        data() {
          return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
        },
      };
      const vm = new Vue({
        el: "#root",
        // 注册组件
        components: { school },
      });
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

总结拓展

  1. 关于组件名:
    1. 一个单词组成:1) 字母全小写;2) 只首字母大写。
    2. 多个单词组成:1) 使用短横线分隔; 2) 每个单词首字母大写(需要脚手架支持)。
    3. 备注:1) 尽可能避开html已有的标签名;2) 可以使用name配置指定组件在开发者工具中呈现的名字。
  2. 关于组件标签:
    1. 可以是<school></school>
    2. 可以是<school/>,在不使用脚手架时,<school/>会导致后续组件不能渲染。
  3. 一个简写方式:const school = Vue.extend(options)可简写为const school = options

# 2.4 组件的嵌套

在第一小节里说过可以将页面拆分成一个个组件,其实组件也能被拆分成一个个组件。这就涉及到组件的嵌套了,很简单,只需要在组件里注册另外一个组件,然后在模板使用注册好的那个组件,这样就完成了一个简单的组件嵌套

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  </head>
  <body>
    <div id="root">
      <!-- 使用school组件 -->
      <school></school>
    </div>
    <script type="text/javascript">
      Vue.config.productionTip = false;
      // 创建student组件
      const student = Vue.extend({
        template: `
                <div>
                    <h2>学生姓名:{{name}}</h2>
                    <h2>学生年龄:{{age}}</h2>
                </div>`,
        data() {
          return { name: "张三", age: 18 };
        },
      });
      // 创建school组件
      const school = Vue.extend({
        template: `
                <div>
                    <h2>学校:{{schoolName}}</h2>
                    <h2>地址:{{schoolAddress}}</h2>
                    <!-- 使用school组件,注册在哪个组件里,就使用在哪个组件的模板中 -->
                    <student></student>
                </div>`,
        data() {
          return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
        },
        // 在组件中注册另一个组件,完成组件嵌套
        components: { student },
      });
      const vm = new Vue({
        el: "#root",
        // 注册school组件
        components: { school },
      });
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

组件嵌套

# 2.5 VueComponent

创建组件其本质是生成了一个函数对象,这个函数是一个VueComponent 构造函数(等待实例化),我们看一下Vue.extend简化源码

Vue.extend = function (extendOptions) {
  // ... 其他逻辑暂时省略
  var Sub = function VueComponent(options) {
    this._init(options);
  };
  // ... 其他逻辑暂时省略
  // 生成VueComponent构造函数并返回
  return Sub;
};
1
2
3
4
5
6
7
8
9

创建了多个组件,那每个组件拥有的VueComponent 构造函数不相同的(上面 return 的是新对象),虽然 VueComponent 实际代码一样,但是它们的执行环境变量对象this都是不一样的。你可以简单测试一下。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  </head>
  <body>
    <div id="root">
      <!-- 使用组件 -->
      <school></school>
      <student></student>
    </div>
    <script type="text/javascript">
      Vue.config.productionTip = false;
      // 创建student组件
      const student = Vue.extend({
        template: `
                <div>
                    <h2>学生姓名:{{name}}</h2>
                    <h2>学生年龄:{{age}}</h2>
                </div>`,
        data() {
          return { name: "张三", age: 18 };
        },
      });
      // 创建school组件
      const school = Vue.extend({
        template: `
                <div>
                    <h2>学校:{{schoolName}}</h2>
                    <h2>地址:{{schoolAddress}}</h2>
                </div>`,
        data() {
          return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
        },
      });
      // 首先验证school和student是个什么东西?其实是个VueComponent构造函数
      console.log("school是什么?", school);
      console.log("student是什么?", student);
      // 再验证不同组件的VueComponent构造函数是否一样,其实不一样,每次Vue.extend都会生成新的
      console.log("student是否与school相同?", school === student);
      school.a = 11;
      console.log("school.a是11,那student.a是多少?", student.a);
      const vm = new Vue({
        el: "#root",
        // 注册组件
        components: { school, student },
      });
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

模板中使用组件,其底层是使用new关键字调用VueComponent 构造函数,将它实例化,生成的实例就是组件实例,通常简称为vcvcvm的结构非常类似,因为VueComponent 构造函数Vue 构造函数都会走this._init(options)逻辑,区别就是各自的options有些不一样,也就是前面说过eldata有些不一样。options里的这些配置methodswatchcomputed,在这些配置里面定义方法,方法的this 指向就是生成的实例,组件就是组件实例vc

// 组件实例化时调用
var Sub = function VueComponent(options) {
  this._init(options);
};
// new Vue()时调用
function Vue(options) {
  this._init(options);
}
1
2
3
4
5
6
7
8

vm可以通过原型使用一些特殊的属性和方法(公共的),比如$watch(),其实vc也能使用$watch()。这涉及到一个重要的内置关系vc原型继承了vm原型,稍微直白点的说法是,vc原型对象可以通过__proto__访问到vm原型对象。可以看一下Vue.extend的简化源码。

// Vue框架中关于VueComponent构造函数
Vue.extend = function (extendOptions) {
  // ... 其他逻辑暂时省略
  // 这里的Super实际上是Vue构造函数
  var Super = this;
  // 都走_init,所以vm有的实例属性和方法,vc几乎都有
  var Sub = function VueComponent(options) {
    this._init(options);
  };
  // 原型式继承,Sub.prototype的__proto__指向了Super.prototype
  Sub.prototype = Object.create(Super.prototype);
  // 让原型上的构造函数引用,重新指向Sub
  Sub.prototype.constructor = Sub;
  // ... 其他逻辑暂时省略
  return Sub; // 返回VueComponent构造函数
};
// js中关于Object.create()的本质,它返回了一个新对象,新对象的__proto__指向了o
function object(o) {
  function F() {} // 定义临时引用类型F的构造函数
  F.prototype = o; // 普通函数的入参对象o作为这个临时引用类型F的原型
  return new F(); // 最后临时引用类型F的实例
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

总结拓展

  1. 组件本质是一个名为VueComponent构造函数,且不是程序员自定义的,是Vue.extend()新生成的函数对象。
  2. 在模板中使用组件,例如<school></school>,Vue 解析时会帮我们创建组件的实例对象(school 组件的实例对象)。
  3. 特别注意:每次调用Vue.extend(),返回的都是一个全新的VueComponent 构造函数。
  4. 关于 this 的指向:
    1. 组件配置对象中,methodswatchcomputed里定义的方法,它们的 this 指向均是 VueComponent 实例(vc)。
    2. new Vue()配置对象中,methodswatchcomputed里定义的方法,它们的 this 指向均是 Vue 实例对象(vm)。
  5. 想要在控制台查看组件的嵌套,可以在vm.$children或者vc.$children进行查看。
  6. 一个重要的内置关系:VueComponent.prototype.__proto__ === Vue.prototype。让实例组件vc能访问到 Vue 原型上的属性和方法。

# 2.6 单文件组件

单文件组件就是一个文件只包含了一个组件。这个单文件,一般存在于Vue 脚手架里,Vue 脚手架学习 vue 的准备工作里介绍安装过。

单文件的命名方式与2.3 组件的几个注意点这节里组件命名方式一样,在脚手架里常使用每个单词首字母大写的方式。单文件的样子就是下面例子Xxx.vue,它只有三种标签<template>组件的模板、<script>脚本、<style>样式,比较方便复用。

<template>
  <!-- 模板,组件的结构 -->
</template>
<script>
// 组件交互相关的代码(数据、方法等)
</script>
<style>
/* 组件的样式 */
</style>
1
2
3
4
5
6
7
8
9

我们将前面几节的例子修改单文件组件形式(可以先将脚手架的src/main.jspublic/index.html删除),首先准备School.vueStudent.vue,这两个文件我们放在了一个src/components文件夹下。

<template>
  <div>
    <h2>学校:{{ schoolName }}</h2>
    <h2>地址:{{ schoolAddress }}</h2>
  </div>
</template>

<script>
// 将创建的组件,以模块的方式导出去,Vue.extend()省略了
export default {
  name: "School",
  data() {
    return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
  },
};
</script>

<style></style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
  <div>
    <h2>学生姓名:{{ name }}</h2>
    <h2>学生年龄:{{ age }}</h2>
  </div>
</template>

<script>
// 将组件导出去,省略了Vue.extend()的方式
export default {
  name: "Student",
  data() {
    return { name: "张三", age: 18 };
  },
};
</script>

<style></style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

然后准备一个App.vue,它是一个应用中最大的组件,管理所有的组件,它就放在src目录里。

<template>
  <div>
    <School></School>
    <Student></Student>
  </div>
</template>

<script>
import School from "./components/School.vue";
import Student from "./components/Student.vue";

export default {
  name: "App",
  components: {
    School,
    Student,
  },
};
</script>

<style></style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

此时vm实例还没有准备,那就新建一个文件main.js,这个main.js是 js 入口文件,它放在src目录里。

import Vue from "vue";
import App from "./App.vue";

Vue.config.productionTip = false;

/*
    在脚手架里默认不能使用`template`字段,下一章节会解释。可以在项目根目录新建vue.config.js,
    并在里面加上`module.exports={runtimeCompiler: true}`,然后重启项目即可消除限制。
*/
new Vue({
  el: "#app",
  name: "App",
  template: `<App></App>`,
  components: {
    App,
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

最后就是准备一个浏览器能识别的 html 入口文件index.html,它其实是在public目录里。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <!-- 针对IE浏览器的一个特殊配置,含义是让IE浏览器以最高的渲染级别渲染页面 -->
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <!-- 开启移动端的理想视口 -->
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="app"></div>
    <!-- 在脚手架里,会自动注入这些js,无需手动引入 -->
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
    <script src="./main.js"></script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

你可以将上面的几个文件拿到Vue 脚手架里对应目录下,稍加修改就可以运行使用了。

# 三、组件相关零碎知识

# 3.1 解析成虚拟 DOM 的方式

渲染页面解析成虚拟 DOM 有三种方式:解析模板标签、使用render 函数、解析配置对象的template 字段。前两者用得较多,最后一个很少使用了。

  • 解析模板标签需要专用的编译器,这个在项目的package.json中可以找到,叫做vue-template-compiler
  • render 函数使用了createElement
  • 解析 template 字段也是用了一个编译器(要与模板标签的区分开),在项目中它是默认不开启的。

因为解析 template 字段的编译功能默认在脚手架项目中不开启,所以在上一章最后一节main.js里,遇到了问题。问题是得到了解决,在项目的目录下新建vue.config.js文件,然后在里面写上如下配置(添加 webpack 有关配置),这样就开启了解析 template 字段的编译器了。(注意改完重启项目)

module.exports = {
  // 开启Compiler模式,让配置对象可以使用`template`字段
  runtimeCompiler: true,
};
1
2
3
4

但是我们不建议开启解析 template 字段的编译功能,原因是与该编译功能的vue.esm.js比较大,比默认使用的vue.runtime.esm.js**大了 30%**的体积。

# 3.2 render 函数

上一章最后一节main.js,是可以将template: '<App></App>'替换成render()render 函数),这样就无需添加runtimeCompiler有关配置,减小打包后的包体积。

配置对象render()是与data()平级的,Vue 在渲染时会调用这个render(),并且会传一个 xxx 参数给render(xxx)。要注意,xxx 的实参是 Vue 提供给我们的一个函数,它是一个用来生成虚拟 DOM 元素的函数。生成虚拟 DOM 以后,会return给 Vue,Vue 会拿这个虚拟 DOM 去生成真实 DOM,完成当前页面或组件的渲染。

xxx形参名可以是createElement也可以是h(自定义名字),使用createElement()时,它的入参可以是原生元素相关,也可以是子组件

import Vue from "vue";
import App from "./App.vue";
Vue.config.productionTip = false;
new Vue({
  el: "#app",
  /*
    render函数替代了`template`字段。createElement指向的是一个函数,
    该函数是由Vue提供的,用来生成虚拟DOM。生成的虚拟DOM最后要return给Vue的。
    createElement的入参可以是原生元素相关,也可以子组件。createElement
    这个名是自定义的,也可以是h。
  */
  render(createElement) {
    // 如果使用原生元素,第一个参数是标签名,第二个是标签体
    // return createElement('h1', '你好');
    // 注册子组件后,在这里使用子组件,然后就可以展示到当前组件里
    return createElement(App);
  },
  // 在当前组件里注册子组件
  components: {
    App,
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

总结拓展

  1. 渲染页面解析成虚拟 DOM 有三种方式:解析模板标签、使用render 函数、解析配置对象的template 字段
  2. 脚手架项目默认不开启解析 template 字段的编译功能,因为前两种基本够用,还能减小打包的包体积。
  3. 如果存 js 文件(非 vue 文件)可以使用render 函数来渲染组件或页面,render 函数的形参指向一个函数。该形参就是具体用于生成虚拟 DOM 的函数,接收的参数可以是原生元素相关或者子组件

# 3.3 ref 特殊属性

我们在列表渲染相关章节里介绍过key,它是一个特殊的 attribute。这一小节要介绍另外一个特殊的 attribute,ref常用来替代id,用于获取真实 DOM 元素或者子组件的实例。在模板中使用ref="xxx"来给原生元素或子组件进行注册引用信息,然后在某个方法中使用this.$refs.sch来获取对应的真实 DOM子组件实例

<template>
  <div>
    <!-- 使用的id,获取时使用document.getElementById -->
    <!-- <h2 id="sch">学校:{{ schoolName }}</h2> -->
    <!-- 使用的ref,获取时使用this.$refs.xxx -->
    <h2 ref="sch">学校:{{ schoolName }}</h2>
    <h2>地址:{{ schoolAddress }}</h2>
    <button @click="getEl">点击获取学校名的元素</button>
    <!-- 在子组件上使用ref,获取时this.$refs.xxx,得到的是子组件的实例对象 -->
    <Student ref="stud"></Student>
  </div>
</template>

<script>
import Student from "./Student.vue";
export default {
  name: "School",
  components: {
    Student,
  },
  data() {
    return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
  },
  methods: {
    getEl() {
      // 返回的是学校名有关的那个h2元素
      console.log("通过ref获取的元素1:", this.$refs.sch);
      // 返回的是Student组件的实例
      console.log("通过ref获取的元素2:", this.$refs.stud);
    },
  },
};
</script>

<style></style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

# 3.4 props 数据流向

组件中调用组件,有时候需要传递组件一些初始化数据,子组件拿这些数据进行渲染

我们使用props 方式传参:在子组件被调用时的标签里(编写标签时),把传参作为该标签的属性;那么对应的,在组件内部要使用props 配置项进行参数的接收。如下代码,我们将School作为Student组件,在子组件被调用处的<Student>标签里加上nameage属性形式的传参,子组件 Student 内部使用props: ["name", "age"]配置项进行接收。

父组件 School.vue

<template>
  <div>
    <h2>学校:{{ schoolName }}</h2>
    <h2>地址:{{ schoolAddress }}</h2>
    <Student name="张三" :age="18"></Student>
    <Student name="李四" :age="20"></Student>
  </div>
</template>

<script>
import Student from "./Student.vue";
export default {
  name: "School",
  components: {
    Student,
  },
  data() {
    return { schoolName: "武汉大学", schoolAddress: "武汉市武昌区珞珈山路" };
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

子组件 Student.vue

<template>
  <div>
    <h1>{{ msg }}</h1>
    <h2>学生姓名:{{ name }}</h2>
    <h2>学生年龄:{{ age }}</h2>
  </div>
</template>

<script>
export default {
  name: "Student",
  // 使用字符串数组来接收父组件传来的数据
  props: ["name", "age"],
  data() {
    return { msg: "我是一个学生" };
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • 在使用props 方式进行传参有几个注意点

    1. 如果传参数据是静态字符串,就直接使用属性;如果是动态的或者非字符串,那就使用v-bind进行动态绑定(js 表达式)。

    2. 如果你想将一个对象的所有属性都传过去,那使用v-bind="obj",子组件内部使用对象属性名来接收;如果是将整个对象传过去就v-bind:param="obj",子组件内部使用一个对象比如param来接收。

      // 将某个对象的所有属性通过props方式传过去 post: { id: 1, title: 'My Journey with Vue'} //
      父组件模板里编写子组件标签,添上v-bind="post"
      <blog-post v-bind="post"></blog-post>
      // 与上面的写法效果完全一样,这个写法更麻烦
      <blog-post v-bind:id="post.id" v-bind:title="post.title"></blog-post>
      // 接收时就使用属性名来接收 props: ['id', 'title']
      
      1
      2
      3
      4
      5
      6
    3. 子组件内部的props配置项是与datamethods平级的,props配置项用得最多的形式是字符串数组,Vue 初始化时会对这个数组进行解析,并将数组里解析好的字段放到this上(组件实例),也就是可以通过this直接访问这些字段(先于 data 解析)。

  • 另外,我们必须记住父子组件间的 props 是由父到子的单向数据流动(父通过 props 传数据给子,但子不能修改 props):

    1. 父组件里的 data 数据变化时,会引起父组件模板重新解析。如果父组件模板里的子组件使用了props 方式传参,并且这个参数跟着父组件的 data变化了,那么子组件内部的props 配置项也会变化,子组件的模板也会重新解析。如果只是单纯的 props 方式传参,但是这个参数并没有随着父组件 data 的变化而变化,那子组件的props 配置项以及模板都不会有变化,这一点非常重要!!!不是父组件变动了子组件就一定会变,只有props 数据变了才会重新解析子组件的模板
    2. 子组件内部自己对 props 配置项进行修改,并不会引发父组件的数据以及模板的变化。如果要在子组件内部修改 props应该怎么办?正确做法是使用data的变量转存一份props(用 computed 的变量来转存也可以),但是不能同名,控制台会报错;即使同名,也只会生效 props 的,这意味着 props 的解析先于data(data 的同名并不会覆盖 props 里的)。要记住,props 配置项是只读的,你不能修改它,否则会报错
    3. 但是有一个漏洞,如果 props 传过来的是一个完整的对象时(不是v-bind="obj"而是v-bind:param="obj"),直接修改对象的引用会报错(相当于重新赋一个新对象给引用,所以会报错),去修改对象上的某个属性就能修改成功的。不过不建议这么做!
<template>
  <div>
    <h1>{{ msg }}</h1>
    <h2>学生姓名:{{ name }}</h2>
    <h2>学生年龄:{{ myAge }}</h2>
    <!-- 不要妄想直接修改age,props是只读数据,这种数据流是单向的 -->
    <button @click="myAge++">我想改一下年龄</button>
  </div>
</template>

<script>
export default {
  name: "Student",
  // 使用字符串数组来接收父组件传来的数据
  props: ["name", "age"],
  data() {
    // 想改props里的age数据,那就使用data里的变量进行转存,但不能同名
    // props先于data解析,所以这里能访问this.age也就是props里的age
    return { msg: "我是一个学生", myAge: this.age };
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

props还有另外两种写法,是为了对 props 进行验证,用的比较少。一种方式是只限制数据类型另一种方式是限制数据类型、限制必要性、指定默认值

<template>
  <div>
    <h1>{{ msg }}</h1>
    <h2>学生姓名:{{ name }}</h2>
    <h2>学生年龄:{{ age }}</h2>
  </div>
</template>

<script>
export default {
  name: "Student",
  // 限制属性的类型
  /* props: {
    name: String,
    age: Number,
  }, */
  // 限制类型、限制必要性、指定默认值。只有在非必要时才指定默认值。
  props: {
    name: {
      type: String,
      require: true, // 必要的
    },
    age: {
      type: Number,
      require: false, // 非必要的,父组件那里就可以不用传了
      default: 50, // 默认值,如果父组件那里没有传就是用默认值
    },
  },
  data() {
    return { msg: "我是一个学生" };
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

总结拓展

  1. props 配置项:让组件接收外部传来的数据。
  2. 三种写法:
    1. props: ['name', 'age']只用于接收;
    2. props: { name: String, age: Number },接收并限制类型;
    3. props: { age: { type: Number, require: false, default: 50 }},接收并限制类型、限制必要性、指定默认值。
  3. 父组件到子组件的 props 是单向的数据流动。
    1. 父组件的 data 变化,会让父组件模板重新解析,然后子组件的 props 数据如果随着父组件 data 变化了,那子组件的模板也会重新解析,否则就算用了 props 方式传值,只要props 数据不变就不会让子组件模板重新解析。
    2. 子组件里的 props 配置项是只读的,修改就会报错。若想要修改 props 配置项里的数据,请将 props 数据复制到 data 里(computed 也可),但不能同名
    3. 其实还能总结一条:子组件重新渲染只依赖于 data 和 props 的变化。

# 3.5 mixin 混入

在使用不同组件时,你可能会遇到他们的某些配置datamethods等)是完全相同的或者一部分相同),这其实是可以进行代码复用的,Vue 给我们提供了配置共用的功能——mixin 混入

首先是将***共用的配置提取到一个单独的 js 文件里,这个 js 文件用于存放混入对象**,混入对象的各个属性就是共用datamethodsmounted等,最后 export 出去。然后在组件里使用,先将混入对象import 进来,再将混入对象里存的共用配置与组件本地的配置进行混合或者说合并,具体会涉及到一个新配置项mixins: [xxx](数组形式),xxx 就是混入对象

注意不是data整体覆盖了data(也不是methods覆盖了methods),是data中的属性进行合并(是methods中的方法进行合并),当然,不止是datamethods,还有computedwatch等。

比如下面这个例子,我们将SchoolStudent调成平级的,并让他们的methods配置一样。我们在components目录下新建一个mixin.js(名字自定义),在这里放的是被复用的配置(代码完全相同)。

// 分别导出 混入对象m1
export const m1 = {
    // School和Student的methods配置一样,所以被单独抽到这里
    methods: {
        showName() { alert(this.name); },
    },
}
// 分别导出 混入对象m2
export const m2 = { // ... }
1
2
3
4
5
6
7
8
9

School.vue

<template>
  <div>
    <h2 @click="showName">学校:{{ name }}</h2>
    <h2 @click="showMsg">地址:{{ address }}</h2>
  </div>
</template>

<script>
// 导入混入对象m1
import { m1 } from "./mixin";
export default {
  name: "School",
  data() {
    return { name: "武汉大学", address: "武汉市武昌区珞珈山路" };
  },
  // 使用mixins配置项,将公用的methods和组件本地的methods混在一起
  mixins: [m1],
  methods: {
    // 与Student的showMsg代码不一样,所以它不能提取到mixin.js
    showMsg() {
      alert(this.address);
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

Student.vue

<template>
  <div>
    <h2 @click="showName">学生姓名:{{ name }}</h2>
    <h2 @click="showMsg">学生年龄:{{ age }}</h2>
  </div>
</template>

<script>
// 导入混入对象m1
import { m1 } from "./mixin";
export default {
  name: "Student",
  data() {
    return { name: "张三", age: 20 };
  },
  // 使用mixins配置项,将公用的methods和组件本地的methods混在一起
  mixins: [m1],
  methods: {
    // 与School的showMsg代码不一样,所以它不能提取到mixin.js
    showMsg() {
      alert(this.age);
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

datamethods等配置项,如果混合对象组件本地同名的属性或方法,那么合并的时候以组件本地为主。而mounted这种生命周期钩子被提取到混合对象里,并且组件本地也写了mounted生命钩子,那两个地方的生命周期都会运行(mixin 运行一遍,组件本地运行一遍),并不像datamethods等的合并。


总结拓展

  1. mixin 混入:可以把多个组件共用的配置提取成一个混入对象
  2. 使用方法:
    1. 定义混入对象:const m1 = {methods: {}}
    2. 使用混入对象:在配置对象中加入mixins: [m1],前提是 import 导入进来了。
  3. 以上是局部混入,其实也能全局混入,使用得很少。全局混入定义时Vue.mixin(xxx),会给每个vc以及vm混入配置。全局混入在使用时不再需要mixins:[m1, m2]配置项了。

# 3.6 插件

插件是用于增强Vue 的功能,比如过滤器自定义指令mixin 混入都可以放到Vue上进行全局使用。

插件是定义在一个单独的 js 里,它具有一个重要的方法叫做install,在这个方法中进行过滤器、自定义指令、minxin 混入等全局注册。install(Vue, a, b, c)的第一个入参就是Vue 构造函数,后面的入参是使用处Vue.use(plugins, a, b c)中出传的 a、b、c。

新建一个plugins.js

// 定义一个插件
export default {
  install(Vue) {
    // 全局配置
    Vue.config.productionTip = false;
    // 全局过滤器
    Vue.filter("addStr", function (value) {
      return value + "测试结果成功";
    });
    // 全局自定义指令
    Vue.directive("big", function (element, binding) {
      element.innerText = binding.value + "测试结果成功";
    });
    // 全局mixin混入
    Vue.mixin({
      data() {
        return {
          test1: "测试全局过滤器:",
          test2: "测试全局自定义指令:",
        };
      },
      methods: {
        showName() {
          alert(this.name);
        },
      },
    });
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

main.js中使用插件:

import Vue from "vue";
import App from "./App.vue";
import plugins from "./plugins";

// 使用插件
Vue.use(plugins);

new Vue({
  el: "#app",
  render: (h) => h(App),
  components: { App },
});
1
2
3
4
5
6
7
8
9
10
11
12

School.vue中使用全局过滤器、全局自定义指令、全局混入:

<template>
  <div>
    <!-- showName是全局混入的一个方法,name是组件本地data中的数据 -->
    <h2 @click="showName">学校:{{ name }}</h2>
    <!-- address是组件本地data数据 -->
    <h2>地址:{{ address }}</h2>
    <!-- test1是全局混入的data数据,addStr是全局过滤器 -->
    <h2>{{ test1 | addStr }}</h2>
    <!-- test2是全局混入的data数据,v-big是全局自定义指令 -->
    <h2 v-big="test2"></h2>
  </div>
</template>

<script>
export default {
  name: "School",
  data() {
    return { name: "武汉大学", address: "武汉市武昌区珞珈山路" };
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

Student.vue中使用全局过滤器、全局自定义指令、全局混入:

<template>
  <div>
    <!-- showName是全局混入的一个方法,name是组件本地data中的数据 -->
    <h2 @click="showName">学生姓名:{{ name }}</h2>
    <!-- age是组件本地data数据 -->
    <h2>学生年龄:{{ age }}</h2>
    <!-- test1是全局混入的data数据,addStr是全局过滤器 -->
    <h2>测试全局过滤器:{{ test1 | addStr }}</h2>
    <!-- test2是全局混入的data数据,v-big是全局自定义指令 -->
    <h2 v-big="test2"></h2>
  </div>
</template>

<script>
export default {
  name: "Student",
  data() {
    return { name: "张三", age: 20 };
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

总结拓展

  1. 插件功能:用于增强 Vue。
  2. 插件本质:包含 install 方法的一个对象,install 第一个参数是 Vue,之后的参数是插件使用者传入的。
  3. 定义插件:对象.install = function(Vue, a, b, c) {},install 方法中加Vue.filter(...)Vue.directive()等。
  4. 使用插件:Vue.use(对象, a, b, c),这个对象就是上面那个“对象”,abc 是多余可以自己传的参数。

# 3.7 scoped 样式

两个平级的组件,在使用样式的时候,遇到同名 class,它们 class 对应的样式各不相同,但是只生效后引用的同名 class样式。Vue 其实给我们解决了不同组件的同名 class样式冲突的问题,只需要在将scoped加到<style>上就可以解决,即<style scoped></style>

需要注意的是App.vue里经常加公用样式,所以App.vue<style>里很少加上scoped

School.vue

<template>
  <div class="test">
    <h2 class="title">学校:{{ name }}</h2>
    <h2>地址:{{ address }}</h2>
  </div>
</template>

<script>
export default {
  name: "School",
  data() {
    return { name: "武汉大学", address: "武汉市武昌区珞珈山路" };
  },
};
</script>
<!-- 加上scoped解决class同名问题 -->
<style scoped>
.test {
  background-color: blue;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

Student.vue

<template>
  <div class="test">
    <h2 class="title">学生姓名:{{ name }}</h2>
    <h2>学生年龄:{{ age }}</h2>
  </div>
</template>

<script>
export default {
  name: "Student",
  data() {
    return { name: "张三", age: 20 };
  },
};
</script>
<!-- 加上scoped解决class同名问题 -->
<style scoped>
.test {
  background-color: green;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

main.js

import Vue from "vue";
import App from "./App.vue";

new Vue({
  el: "#app",
  render: (h) => h(App),
  components: { App },
});
1
2
3
4
5
6
7
8

# 四、TodoList 案例

# 4.1 将页面拆分成组件

TodoList静态图

TodoList 静态图如下,这一节是将 TodoList拆分成一个个组件,要注意的是按照功能点来拆分。将顶部的输入框作为AddTodo组件,将中间的 Todo 展示列表作为TodoMain组件,将底部的删除、清空等按钮取作为TotalTodo组件TodoMain组件是可以进一步拆分成一个个TodoItem组件的。最后将AddTodoTodoMainTotalTodo放入App.vue里。

将TodoList拆分成组件

容器App.vue

<template>
  <div class="todo-container">
    <AddTodo></AddTodo>
    <TodoMain></TodoMain>
    <TotalTodo></TotalTodo>
  </div>
</template>

<script>
import AddTodo from "./components/AddTodo.vue";
import TodoMain from "./components/TodoMain.vue";
import TotalTodo from "./components/TotalTodo.vue";

export default {
  name: "App",
  components: {
    AddTodo,
    TodoMain,
    TotalTodo,
  },
};
</script>

<style>
/*base*/
body {
  background: #fff;
}
.btn {
  display: inline-block;
  padding: 4px 12px;
  margin-bottom: 0;
  font-size: 14px;
  line-height: 20px;
  text-align: center;
  vertical-align: middle;
  cursor: pointer;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
  border-radius: 4px;
}
.btn-danger {
  color: #fff;
  background-color: #da4f49;
  border: 1px solid #bd362f;
}
.btn-danger:hover {
  color: #fff;
  background-color: #bd362f;
}
.btn:focus {
  outline: none;
}
.todo-container {
  width: 600px;
  margin: 0 auto;
}
.todo-container .todo-wrap {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 5px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

顶部的输入框AddTodo.vue

<template>
  <div class="todo-header">
    <input type="text" placeholder="请输入你的任务名称,按回车键确认" />
  </div>
</template>

<script>
export default {
  name: "AddTodo",
};
</script>

<style scoped>
/*header*/
.todo-header input {
  width: 585px;
  height: 28px;
  font-size: 14px;
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 4px 7px;
}
.todo-header input:focus {
  outline: none;
  border-color: rgba(82, 168, 236, 0.8);
  box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

中间的 Todo 展示区域TodoMain.vue

<template>
  <ul class="todo-main">
    <TodoItem></TodoItem>
    <TodoItem></TodoItem>
    <TodoItem></TodoItem>
  </ul>
</template>

<script>
import TodoItem from "./TodoItem.vue";
export default {
  name: "TodoMain",
  components: { TodoItem },
};
</script>

<style scoped>
/*main*/
.todo-main {
  margin-left: 0px;
  border: 1px solid #ddd;
  border-radius: 2px;
  padding: 0px;
}
.todo-empty {
  height: 40px;
  line-height: 40px;
  border: 1px solid #ddd;
  border-radius: 2px;
  padding-left: 5px;
  margin-top: 10px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

底部的删除清空按钮区域TotalTodo.vue

<template>
  <div class="todo-footer">
    <label>
      <input type="checkbox" />
    </label>
    <span> <span>已完成0</span> / 全部2 </span>
    <button class="btn btn-danger">清除已完成任务</button>
  </div>
</template>

<script>
export default {
  name: "TotalTodo",
};
</script>

<style scoped>
/*footer*/
.todo-footer {
  height: 40px;
  line-height: 40px;
  padding-left: 6px;
  margin-top: 5px;
}
.todo-footer label {
  display: inline-block;
  margin-right: 20px;
  cursor: pointer;
}
.todo-footer label input {
  position: relative;
  top: -1px;
  vertical-align: middle;
  margin-right: 5px;
}
.todo-footer button {
  float: right;
  margin-top: 5px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

TodoMain里的TodoItem

<template>
  <li>
    <label>
      <input type="checkbox" />
      <span>xxxxx</span>
    </label>
    <button class="btn btn-danger">删除</button>
  </li>
</template>

<script>
export default {
  name: "TodoItem",
};
</script>

<style scoped>
/*item*/
li {
  list-style: none;
  height: 36px;
  line-height: 36px;
  padding: 0 5px;
  border-bottom: 1px solid #ddd;
}
li label {
  float: left;
  cursor: pointer;
}
li label li input {
  vertical-align: middle;
  margin-right: 6px;
  position: relative;
  top: -1px;
}
li button {
  float: right;
  display: none;
  margin-top: 3px;
}
li:before {
  content: initial;
}
li:last-child {
  border-bottom: none;
}
li:hover {
  background-color: #ddd;
}
li:hover button {
  display: block;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

# 4.2 初始化操作-列表渲染

初始化列表,首先在 data 里构造一个数组todos,每一项存储id编号、title名称、done是否完成;然后使用v-fortodos进行遍历来构造展示 todo 列表,在<TodoItem>里使用v-for="todo in todos",不要忘记加上:key="todo.id";再就是要给TodoItem子组件进行传参,准备一个属性todoItem,并动态绑定单项数据,即:todoItem="todo";最后TodoItem要接收父组件传来的数据,使用props: ['todoItem']

TodoMain.vue

<template>
  <ul class="todo-main">
    <TodoItem v-for="todo in todos" :key="todo.id" :todoItem="todo"></TodoItem>
  </ul>
</template>

<script>
import TodoItem from "./TodoItem.vue";
export default {
  name: "TodoMain",
  components: { TodoItem },
  data() {
    return {
      todos: [
        { id: "001", title: "吃饭", done: true },
        { id: "002", title: "学习", done: false },
        { id: "003", title: "游戏", done: false },
      ],
    };
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

TodoItem.vue接收数据,并可以对checkboxspan绑定值:

<template>
  <li>
    <label>
      <input type="checkbox" :checked="todoItem.done" />
      <span>{{ todoItem.title }}</span>
    </label>
    <button class="btn btn-danger">删除</button>
  </li>
</template>

<script>
export default {
  name: "TodoItem",
  props: ["todoItem"],
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 4.3 添加操作-状态提升

前一小节todos 数据是存放在TodoMain里进行列表渲染,而这一小节要开发AddTodo组件的添加 item 功能,这样就会用到todos 数据。这两个平级的组件都会读取或修改todos 数据,那就涉及到组件间通信了。

在前面章节里只说过父组件向子组件传参使用props 方式,这是一种父到子的单向通信。而这一小节主要讲一个最简单组件间通信(双向),它就是状态提升。其实在后面章节里会讲全局事件总线vuex高级组件间通信技术,但这是后话了。

所谓的状态提升,就是将多个组件的共用数据提升组件里。如果哪个组件想读取数据了(为了展示渲染),就通过props 方式需要的数据传递给组件(父===>子);重点来了,如果哪个组件想操作数据了(为了修改父组件里的原始数据),就将定义在父组件里的“操作数据的方法”,通过props 方式将该方法的引用来传递给组件,那么子组件手握该方法引用并可以调用它去修改父组件里的原始数据了(子===>父)。

按照状态提升,我们假设A组件修改了父组件里的存储数据,并且该数据在之前就通过porps 方式传给了B组件,那么该数据被A修改B就会重新读取(至于B为什么重新读取,可以看3.4 props 数据流向这一节内容)。这样的情景反过来,B修改数据那A也会重新读取,这就达到了AB进行了双向通信的目的。父组件充当了子组件双向通信的一个桥梁

我们要对上一小节的代码做简单的修改,将todos数据存到App这个父组件里,子组件TodoMain通过 props 配置接收传来的todos数据。还有,父组件App提前修改数据方法通过 props 传递给AddTodo

App.vue

<template>
  <div class="todo-container">
    <!-- 要给AddTodo提供修改todos数据的方法 -->
    <AddTodo :addTodo="addTodo"></AddTodo>
    <!-- 要将todos数据传给TodoMain进行展示 -->
    <TodoMain :todos="todos"></TodoMain>
    <TotalTodo></TotalTodo>
  </div>
</template>

<script>
import AddTodo from "./components/AddTodo.vue";
import TodoMain from "./components/TodoMain.vue";
import TotalTodo from "./components/TotalTodo.vue";

export default {
  name: "App",
  components: {
    AddTodo,
    TodoMain,
    TotalTodo,
  },
  data() {
    return {
      // 初始化todos数组,状态提升,将数据放到两个组件的父组件里,通过父组件这个桥梁进行通信
      todos: [
        { id: "001", title: "吃饭", done: true },
        { id: "002", title: "学习", done: false },
        { id: "003", title: "游戏", done: false },
      ],
    };
  },
  methods: {
    // 通过props的方式将addTodo方法暴露给AddTodo去使用,是为了修改todos数据
    addTodo(item) {
      // unshift方法可以触发模板重新解析
      this.todos.unshift(item);
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

TodoMain.vue

<template>
  <ul class="todo-main">
    <!-- 使用App传来的todos数组 -->
    <TodoItem v-for="todo in todos" :key="todo.id" :todoItem="todo"></TodoItem>
  </ul>
</template>

<script>
import TodoItem from "./TodoItem.vue";
export default {
  name: "TodoMain",
  components: { TodoItem },
  // 接收App传来的todos数组
  props: ["todos"],
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

AddTodo.vue

<template>
  <div class="todo-header">
    <input type="text" placeholder="请输入你的任务名称,按回车键确认" v-model.trim="title" @keyup.enter="addItem" />
  </div>
</template>

<script>
import { nanoid } from "nanoid";
export default {
  name: "AddTodo",
  // 接收App传来的addTodo,用于修改App里的todos数组
  props: ["addTodo"],
  data() {
    return {
      title: "",
    };
  },
  methods: {
    addItem() {
      if (!this.title) return;
      // 使用nanoid库生成一个唯一id
      const item = { id: nanoid(), title: this.title, done: false };
      // 使用App传来的addTodo,用于修改App里的todos数组(新增一项)
      this.addTodo(item);
      this.title = "";
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 4.4 勾选和删除

3.4 props 数据流向这节里,说过 props 是只读的,但它是对象时,其实能成功修改它的属性值。我们可以利用props这个漏洞搭配v-model,快速实现TodoItem组件里的 checkbox动态响应(勾上 or 取消,对应数据也改变)。

<template>
  <li>
    <label>
      <!-- 利用`props`和v-model实现勾选框与数据之间的动态响应 -->
      <input type="checkbox" v-model="todoItem.done" />
      <span>{{ todoItem.title }}</span>
    </label>
    <button class="btn btn-danger">删除</button>
  </li>
</template>

<script>
export default {
  name: "TodoItem",
  props: ["todoItem"],
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

可以看到上面就用了一行代码就达到了勾选框与数据之间的动态响应,确实是非常方便。但这违背了 props只读的原则,那么就只能用上一小节的状态提升来实现了。在使用状态提升实现 item 的勾选时我们顺带实现一下删除

App.vue

<template>
  <div class="todo-container">
    <AddTodo :addTodo="addTodo"></AddTodo>
    <!-- 通过props将方法传递给子组件 -->
    <TodoMain :todos="todos" :checkTodo="checkTodo" :deleteTodo="deleteTodo"></TodoMain>
    <TotalTodo></TotalTodo>
  </div>
</template>

<script>
import AddTodo from "./components/AddTodo.vue";
import TodoMain from "./components/TodoMain.vue";
import TotalTodo from "./components/TotalTodo.vue";

export default {
  name: "App",
  components: {
    AddTodo,
    TodoMain,
    TotalTodo,
  },
  data() {
    return {
      todos: [
        { id: "001", title: "吃饭", done: true },
        { id: "002", title: "学习", done: false },
        { id: "003", title: "游戏", done: false },
      ],
    };
  },
  methods: {
    addTodo(item) {
      this.todos.unshift(item);
    },
    // 给TodoItem提供的方法,用于修改done字段
    checkTodo(id) {
      if (!id) return;
      for (let i = 0; i < this.todos.length; i++) {
        const item = this.todos[i];
        if (item.id === id) {
          item.done = !item.done;
          break;
        }
      }
    },
    // 给TodoItem提供的方法,用于删除一项
    deleteTodo(id) {
      if (!id) return;
      this.todos = this.todos.filter((item) => item.id !== id);
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

TodoMain.vue

<template>
  <ul class="todo-main">
    <TodoItem
      v-for="todo in todos"
      :key="todo.id"
      :todoItem="todo"
      :checkTodo="checkTodo"
      :deleteTodo="deleteTodo"
    ></TodoItem>
  </ul>
</template>

<script>
import TodoItem from "./TodoItem.vue";
export default {
  name: "TodoMain",
  components: { TodoItem },
  props: ["todos", "checkTodo", "deleteTodo"],
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

TodoItem.vue

<template>
  <li>
    <label>
      <input type="checkbox" :checked="todoItem.done" @change="handleCheck(todoItem.id)" />
      <span>{{ todoItem.title }}</span>
    </label>
    <button class="btn btn-danger" @click="handleDelete(todoItem.id)">删除</button>
  </li>
</template>

<script>
export default {
  name: "TodoItem",
  props: ["todoItem", "checkTodo", "deleteTodo"],
  methods: {
    handleCheck(id) {
      if (!id) return;
      // 调用App传来的方法,勾选某一项
      this.checkTodo(id);
    },
    handleDelete(id) {
      if (!id) return;
      if (confirm("确实删除吗?")) {
        // 调用App传来的方法,删除某一项
        this.deleteTodo(id);
      }
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# 4.5 底部统计

这一节要完成“全部 todo 数量统计”、“已完成 todo 数量统计”、“全部或取消全部勾选 todo”、“清除已完成的 todo”。前两个需求可以使用计算属性,第三个需求可以使用:checked@change来完成,最后一个需求更简单直接让父组件的数据过滤。

App.vue

<template>
  <div class="todo-container">
    <AddTodo :addTodo="addTodo"></AddTodo>
    <TodoMain :todos="todos" :checkTodo="checkTodo" :deleteTodo="deleteTodo"></TodoMain>
    <!-- 将checkAllTodo和clearAllTodo传递给TotalTodo使用 -->
    <TotalTodo :todos="todos" :checkAllTodo="checkAllTodo" :clearAllTodo="clearAllTodo"></TotalTodo>
  </div>
</template>

<script>
import AddTodo from "./components/AddTodo.vue";
import TodoMain from "./components/TodoMain.vue";
import TotalTodo from "./components/TotalTodo.vue";

export default {
  name: "App",
  components: {
    AddTodo,
    TodoMain,
    TotalTodo,
  },
  data() {
    return {
      todos: [
        { id: "001", title: "吃饭", done: true },
        { id: "002", title: "学习", done: false },
        { id: "003", title: "游戏", done: false },
      ],
    };
  },
  methods: {
    // 上两节内容
    addTodo(item) {
      this.todos.unshift(item);
    },
    // 上一节内容
    checkTodo(id) {
      if (!id) return;
      for (let i = 0; i < this.todos.length; i++) {
        const item = this.todos[i];
        if (item.id === id) {
          item.done = !item.done;
          break;
        }
      }
    },
    // 上一节内容
    deleteTodo(id) {
      if (!id) return;
      this.todos = this.todos.filter((item) => item.id !== id);
    },
    // 这一节内容,勾选全部todo或者取消勾选全部todo
    checkAllTodo(done) {
      this.todos.forEach((item) => (item.done = done));
    },
    // 这一节内容,清除已完成的todo
    clearAllTodo() {
      this.todos = this.todos.filter((item) => !item.done);
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

TotalTodo.vue

<template>
  <div class="todo-footer" v-show="total">
    <label>
      <input type="checkbox" :checked="isAll" @change="checkAll" />
    </label>
    <span>
      <span>已完成{{ doneTotal }}</span> / 全部{{ total }}
    </span>
    <button class="btn btn-danger" @click="clearAll">清除已完成任务</button>
  </div>
</template>

<script>
export default {
  name: "TotalTodo",
  props: ["todos", "checkAllTodo", "clearAllTodo"],
  computed: {
    // 计算出全部todo的数量
    total() {
      return this.todos.length;
    },
    // 计算出已完成的todo的数量
    doneTotal() {
      // 使用reduce进行统计,accumulator是累计器,currItem是当前操作的项
      const count = this.todos.reduce((accumulator, currItem) => {
        // 只要当前操作的项是已完成的todo,将让累计器加1
        return accumulator + (currItem.done ? 1 : 0);
      }, 0);
      return count;
    },
    // 判断是否全部勾选
    isAll() {
      return this.total > 0 && this.total === this.doneTotal;
    },
  },
  methods: {
    checkAll(e) {
      // 去修改App里的todos所有项的done字段,全部勾选or全部取消
      this.checkAllTodo(e.target.checked);
    },
    clearAll() {
      // 清空App里的todos已完成的todo
      this.clearAllTodo();
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

其实上面TotalTodo.vuecheckbox还有优化的余地,让它勾选的初识化状态与change事件进行合并,也就是让v-model搭配计算属性的 getter/setter。在checkbox初始化时,走计算属性isAllgetter;当checkbox勾选或取消勾选时,走计算属性isAllsetter

<template>
  <div class="todo-footer" v-show="total">
    <label>
      <input type="checkbox" v-model="isAll" />
    </label>
    <span>
      <span>已完成{{ doneTotal }}</span> / 全部{{ total }}
    </span>
    <button class="btn btn-danger" @click="clearAll">清除已完成任务</button>
  </div>
</template>

<script>
export default {
  name: "TotalTodo",
  props: ["todos", "checkAllTodo", "clearAllTodo"],
  computed: {
    total() {
      return this.todos.length;
    },
    doneTotal() {
      const count = this.todos.reduce((accumulator, currItem) => {
        return accumulator + (currItem.done ? 1 : 0);
      }, 0);
      return count;
    },
    // 计算属性isAll搭配v-model,让checkbox初始化时走getter,值变化时让App里的所有todo都勾选or取消勾选上
    isAll: {
      get() {
        return this.total > 0 && this.total === this.doneTotal;
      },
      set(val) {
        this.checkAllTodo(val);
      },
    },
  },
  methods: {
    clearAll() {
      this.clearAllTodo();
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

总结拓展

  1. 组件化编码流程:
    1. 拆分静态组件:组件要按照功能点拆分,命名不要与 html 元素冲突。
    2. 实现动态组件:考虑好数据的存放位置,数据是一个组件在用,还是一些组件在用。
      • 一个组件在用:放在组件自身即可。
      • 一些组件在用:放在它们共同的父组件上。(状态提升
    3. 实现交互:从绑定事件开始。
  2. props 适用于:
    1. 父组件===>子组件 通信
    2. 子组件===>父组件 通信(要求父组件先给子组件传一个函数,修改数据)
  3. 使用v-model时要切记:v-model绑定的值不能是 props 传递过来的值,因为 props 是只读的。
  4. props 传过来的若是对象类型的值,修改对象中的属性时 Vue不会报错,但不推荐这样做。

# 4.6 TodoList 本地缓存

浏览器缓存有localStoragesessionStorage,它们大概支持5M大小的内容。一直存在的缓存是 localStorage,浏览器关闭后清除缓存的是 sessionStorage。它们的 API 是一样的:setItem()新增或修改一条、getItem()读取一条、removeItem()删除一条、clear()清空所有。特别注意,setItem()在存储非字符串数据时,会自动将它转为字符串,如果要存对象类型的数据,先手动使用JSON.stringify(obj)进行转换。

关于 localStorage

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
  </head>
  <body>
    <h2>localStorage</h2>
    <button onclick="saveData()">保存一份localStorage</button>
    <button onclick="readData()">读取一份localStorage</button>
    <button onclick="deleteData()">删除一份localStorage</button>
    <button onclick="deleteAllData()">清空所有localStorage</button>
    <script type="text/javascript">
      function saveData() {
        // 添加or修改一条localStorage
        localStorage.setItem("name", "张三");
        localStorage.setItem("age", 18);
        const person = { name: "李四", age: 20 };
        // 对象要先转成json字符串,因为setItem会将非字符串的值转为字符串
        localStorage.setItem("person", JSON.stringify(person));
      }
      function readData() {
        // 读取一条localStorage
        console.log("name:", localStorage.getItem("name"));
        console.log("age:", localStorage.getItem("age"));
        const result = localStorage.getItem("person");
        // 将json字符串转为对象
        console.log("person:", JSON.parse(result));
      }
      function deleteData() {
        // 移除一条localStorage
        localStorage.removeItem("name");
      }
      function deleteAllData() {
        // 清空所有localStorage
        localStorage.clear();
      }
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

在前面几节的 todolist,每次刷新页面,数据都是固定写死的。我们可以使用localStoragesessionStorage进行浏览器缓存,在 todolist 我们需要使用监视属性todos进行监视。

<template>
  <div class="todo-container">
    <AddTodo :addTodo="addTodo"></AddTodo>
    <TodoMain :todos="todos" :checkTodo="checkTodo" :deleteTodo="deleteTodo"></TodoMain>
    <TotalTodo :todos="todos" :checkAllTodo="checkAllTodo" :clearAllTodo="clearAllTodo"></TotalTodo>
  </div>
</template>

<script>
import AddTodo from "./components/AddTodo.vue";
import TodoMain from "./components/TodoMain.vue";
import TotalTodo from "./components/TotalTodo.vue";

export default {
  name: "App",
  components: {
    AddTodo,
    TodoMain,
    TotalTodo,
  },
  data() {
    return {
      // 初始化时读取localStorage,没读取到就用[]
      todos: JSON.parse(localStorage.getItem("todos")) || [],
    };
  },
  watch: {
    todos: {
      // 开启深度监视,能监视到done字段
      deep: true,
      handler(value) {
        // todos数据变化时,将新数据存储到localStorage里
        const jsonStr = JSON.stringify(value);
        localStorage.setItem("todos", jsonStr);
      },
    },
  },
  methods: {
    addTodo(item) {
      this.todos.unshift(item);
    },
    checkTodo(id) {
      if (!id) return;
      for (let i = 0; i < this.todos.length; i++) {
        const item = this.todos[i];
        if (item.id === id) {
          item.done = !item.done;
          break;
        }
      }
    },
    deleteTodo(id) {
      if (!id) return;
      this.todos = this.todos.filter((item) => item.id !== id);
    },
    checkAllTodo(done) {
      this.todos.forEach((item) => (item.done = done));
    },
    clearAllTodo() {
      this.todos = this.todos.filter((item) => !item.done);
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

# 五、自定义事件和全局事件总线

# 5.1 绑定自定义事件

在上一章里,我们使用了状态提升来让平级的组件进行通信。其中有一个重要环节,那就是子组件访问父组件。具体的,父组件通过props传递给子组件一个函数(传的是引用,函数本身还存在于父组件),在需要进行组件访问组件时,子组件内部调用函数(携带新数据),以达到组件访问组件的目的(子组件调用该函数时,将新数据传递给了该函数本身所在的父组件)。

这个重要环节最核心的就是那个传递的“函数”,其实我们可以用“自定义事件”来替代这一步骤。不用传递函数,只需要在绑定自定义事件时把事件回调函数留给父组件,然后让子组件内部触发事件(触发后,底层会调用回调函数),这就能达到组件访问组件的目的(触发时携带新数据,通过事件回调函数传递给父组件)。

具体的,我们在父组件中给子组件标签里,使用v-on绑定自定义事件,该事件的回调函数写在组件的methods里,在组件想访问(修改)组件的存储数据时,让组件内部使用$emit触发那个自定义事件,那么父组件里的那个回调函数就会被调用(达到子访问父的目的)。

App.vue

<template>
  <div class="outer">
    <h2>父组件展示信息:{{ info }}</h2>
    <!-- School组件仍然使用状态提升 -->
    <School :schoolChange="schoolChange" />
    <!-- Student组件使用绑定自定义事件 -->
    <Student @changeInfo="studentChange" />
  </div>
</template>

<script>
import School from "./components/School.vue";
import Student from "./components/Student.vue";
export default {
  name: "App",
  components: {
    School,
    Student,
  },
  data() {
    return { info: "还未有人修改这里" };
  },
  methods: {
    // 回调函数,给School使用
    schoolChange(info) {
      this.info = info;
    },
    // 回调函数,给Student使用
    studentChange(info) {
      this.info = info;
    },
  },
};
</script>

<style>
.outer {
  padding: 10px;
  background-color: dimgray;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

School.vue

<template>
  <div class="school">
    <h2>学校:{{ name }}</h2>
    <h2>地址:{{ address }}</h2>
    <button @click="showInfo">点击我,修改父组件展示信息</button>
  </div>
</template>

<script>
export default {
  name: "School",
  // 状态提升,props需要接收schoolChange回调函数
  props: ["schoolChange"],
  data() {
    return {
      name: "武汉大学",
      address: "武汉市武昌区珞珈山路",
    };
  },
  methods: {
    showInfo() {
      // 调用schoolChange这个回调函数
      this.schoolChange("这里被School子组件修改了");
    },
  },
};
</script>

<style scoped>
.school {
  padding: 20px;
  margin: 20px;
  background-color: forestgreen;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

Student.vue

<template>
  <div class="student">
    <h2>姓名:{{ name }}</h2>
    <h2>年龄:{{ age }}</h2>
    <button @click="showInfo">点击我,修改父组件展示信息</button>
  </div>
</template>

<script>
export default {
  name: "Student",
  data() {
    return {
      name: "张三",
      age: 18,
    };
  },
  methods: {
    showInfo() {
      // 并没有使用props接收函数。只是在这里触发自定义事件,父组件那边的回调函数会被调用,还能携带新数据
      this.$emit("changeInfo", "这里被Student子组件修改了");
    },
  },
};
</script>

<style scoped>
.student {
  padding: 20px;
  margin: 20px;
  background-color: hotpink;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

这种方式简单来说就是,外部绑定事件,内部触发事件;触发时是携带了新数据,将新数据传递给了定义在外部的回调函数(事件内部运行机制)。这种方式不需要通过props进行函数传递(即子组件也无需使用props接收),这比之前的方式更简便。

绑定自定义事件除了上面这种@xxx写法,其实还有另一种写法,使用ref特殊属性标记子组件,然后在组件mounted生命钩子里获取这个子组件实例,再使用子组件实例.$on(事件名, 回调函数)绑定自定义事件第二种写法会比较灵活,可以在异步操作之后绑定自定义事件。

<template>
  <div class="outer">
    <h2>父组件展示信息:{{ info }}</h2>
    <School :schoolChange="schoolChange" />
    <!-- 使用ref特殊属性进行标记,对子组件使用ref,获取的就是子组件实例vc -->
    <Student ref="student" />
  </div>
</template>

<script>
import School from "./components/School.vue";
import Student from "./components/Student.vue";
export default {
  name: "App",
  components: {
    School,
    Student,
  },
  data() {
    return { info: "还未有人修改这里" };
  },
  methods: {
    schoolChange(info) {
      this.info = info;
    },
    studentChange(info) {
      this.info = info;
    },
  },
  mounted() {
    // 先通过ref获取到子组件的实例对象,再给子组件实例对象绑定自定义事件
    this.$refs.student.$on("changeInfo", this.studentChange);
  },
};
</script>

<style>
.outer {
  padding: 10px;
  background-color: dimgray;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

# 5.2 解绑自定义事件

在组件进行销毁this.$destroy())或者它的组件进行销毁时,其组件里绑定的自定义事件会被删除,尽管这样有时候还是需要进行手动解绑自定义事件

<template>
  <div class="outer">
    <h2>父组件展示信息:{{ info }}</h2>
    <School ref="school" />
    <Student ref="student" />
    <button @click="offInfoSchEvent">解绑changeInfoSch自定义事件</button><br /><br />
    <button @click="offInfoStuEvent">解绑changeInfoStu自定义事件</button>
  </div>
</template>

<script>
import School from "./components/School.vue";
import Student from "./components/Student.vue";
export default {
  name: "App",
  components: {
    School,
    Student,
  },
  data() {
    return { info: "还未有人修改这里" };
  },
  methods: {
    schoolChange(info) {
      this.info = info;
    },
    studentChange(info) {
      this.info = info;
    },
    offInfoSchEvent() {
      // 解绑自定义事件changeInfoSch
      this.$refs.school.$off("changeInfoSch");
      // 同时解绑多个,解绑school里的xxx事件和yyy事件
      // this.$refs.school.$off(['xxx', 'yyy']);
      // 同时解绑所有的,解绑school里的所有自定义事件
      // this.$refs.school.$off();
    },
    offInfoStuEvent() {
      // 解绑自定义事件changeInfoStu
      this.$refs.student.$off("changeInfoStu");
      // 同时解绑多个,解绑student里的xxx事件和yyy事件
      // this.$refs.student.$off(['xxx', 'yyy']);
      // 同时解绑所有的,解绑student里的所有自定义事件
      // this.$refs.student.$off();
    },
  },
  mounted() {
    // 给school绑定changeInfoSch自定义事件
    this.$refs.school.$on("changeInfoSch", this.schoolChange);
    // 给student绑定changeInfoStu自定义事件
    this.$refs.student.$on("changeInfoStu", this.studentChange);
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

# 5.3 自定义事件注意点

组件进行自定义事件的绑定,那自定义事件的回调函数一般写在组件的methods里。如果使用this.$refs.xxx.$on('yyy', function(){}),也就是将回调函数直接定义在了第二个参数里,那这个回调函数的this会指向子组件实例,因为是子组件触发的自定义事件。想要与methods方式一样,让this指向父组件实例,那么可以让第二个参数使用箭头函数

  mounted() {
    // 给school绑定changeInfoSch自定义事件
    this.$refs.school.$on("changeInfoSch", (info) => {
      this.info = info;
    });
    // 给student绑定changeInfoStu自定义事件
    this.$refs.student.$on("changeInfoStu", (info) => {
      this.info = info;
    });
  },
1
2
3
4
5
6
7
8
9
10

如果在父组件里给子组件标签里使用v-on来绑定原生事件(前面都是绑定自定义事件),Vue 会认为你绑定的就是自定义事件。要解决这个问题,需要加上事件修饰符.native,让 Vue 将这个事件绑定到组件模板里的那个根元素上。

<template>
  <div class="outer">
    <h2>父组件展示信息:{{ info }}</h2>
    <!-- 绑定一个原生事件。要达到原生的效果,必须加上.native事件修饰符 -->
    <Student @click.native="showMsg" />
  </div>
</template>

<script>
import Student from "./components/Student.vue";
export default {
  name: "App",
  components: {
    Student,
  },
  data() {
    return { info: "还未有人修改这里" };
  },
  methods: {
    showMsg() {
      console.log("绑定了原生的点击事件");
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

总结拓展

  1. 自定义事件是一种组件间通信的方式,适用于 子组件===>父组件。
  2. 使用场景:A 是父组件,B 是子组件,B 想给 A 传数据,那么就要在 A 中给 B 绑定自定义事件(事件的回调在 A 中)。
  3. 绑定自定义事件:
    1. 第一种方式,在父组件中:<Student @xxx="yyy" />,xxx 是自定义事件的名称,yyy 是回调函数。
    2. 第二种方式,在mounted生命钩子中:this.$refs.zzz.$on('xxx', yyy),xxx 是自定义事件的名称,yyy 是回调函数,zzz 是子组件上的 ref 属性值。
    3. 若想让自定义事件只触发一次,可以使用.once事件修饰符,或this.$refs.zzz.$once('xxx', yyy)
  4. 触发自定义事件:this.$emit('xxx', 数据),xxx 是自定义事件的名称。这个触发一般写在子组件里,从子组件传递数据给父组件 。
  5. 解绑自定义事件:this.$off('xxx')this.$refs.zzz.$off('xxx', yyy),在子组件或者父组件里解绑。
  6. 在父组件中可以为子组件绑定原生事件,但需要.native事件修饰符。
  7. 注意:通过this.$refs.zzz.$on('xxx', yyy)绑定自定义事件时,yyy 回调函数要么配置在methods里(this 自然指向父组件实例),要么直接定义在此处但要使用箭头函数(否则 this 会指向子组件实例,一般我们期望它指向父组件实例)。

# 5.4 TodoList 使用自定义事件

在 TodoList 案例中,组件App组件AddTodo组件TodoMain组件TotalTodo组件TodoItem,我们在这一节要对AppAddTodoTotalTodo进行改造,将“通过 props 传递函数”的方式改为“自定义事件”的方式,涉及组件这条线的旧方式我们暂时不动(在下一节会讲)。

App.vue

<template>
  <div class="todo-container">
    <!-- 将`:addTodo="addTodo"`改为自定义事件 -->
    <AddTodo @addTodoEvent="addTodo"></AddTodo>
    <!-- 涉及到孙组件就不改动了 -->
    <TodoMain :todos="todos" :checkTodo="checkTodo" :deleteTodo="deleteTodo"></TodoMain>
    <!-- 将`:checkAllTodo="checkAllTodo"`和`:clearAllTodo="clearAllTodo"`改为自定义事件 -->
    <TotalTodo :todos="todos" @checkAllEvent="checkAllTodo" @clearAllEvent="clearAllTodo"></TotalTodo>
  </div>
</template>

<script>
import AddTodo from "./components/AddTodo.vue";
import TodoMain from "./components/TodoMain.vue";
import TotalTodo from "./components/TotalTodo.vue";

export default {
  name: "App",
  components: {
    AddTodo,
    TodoMain,
    TotalTodo,
  },
  data() {
    return {
      todos: JSON.parse(localStorage.getItem("todos")) || [],
    };
  },
  watch: {
    todos: {
      deep: true,
      handler(value) {
        const jsonStr = JSON.stringify(value);
        localStorage.setItem("todos", jsonStr);
      },
    },
  },
  methods: {
    addTodo(item) {
      this.todos.unshift(item);
    },
    checkTodo(id) {
      if (!id) return;
      for (let i = 0; i < this.todos.length; i++) {
        const item = this.todos[i];
        if (item.id === id) {
          item.done = !item.done;
          break;
        }
      }
    },
    deleteTodo(id) {
      if (!id) return;
      this.todos = this.todos.filter((item) => item.id !== id);
    },
    checkAllTodo(done) {
      this.todos.forEach((item) => (item.done = done));
    },
    clearAllTodo() {
      this.todos = this.todos.filter((item) => !item.done);
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

AddTodo.vue

<template>
  <div class="todo-header">
    <input type="text" placeholder="请输入你的任务名称,按回车键确认" v-model.trim="title" @keyup.enter="addItem" />
  </div>
</template>

<script>
import { nanoid } from "nanoid";
export default {
  name: "AddTodo",
  data() {
    return {
      title: "",
    };
  },
  methods: {
    addItem() {
      if (!this.title) return;
      const item = { id: nanoid(), title: this.title, done: false };
      // 删除props里的addTodo,然后在这里改为$emit触发自定义事件addTodoEvent
      this.$emit("addTodoEvent", item);
      this.title = "";
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

TotalTodo.vue

<template>
  <div class="todo-footer" v-show="total">
    <label>
      <input type="checkbox" v-model="isAll" />
    </label>
    <span>
      <span>已完成{{ doneTotal }}</span> / 全部{{ total }}
    </span>
    <button class="btn btn-danger" @click="clearAll">清除已完成任务</button>
  </div>
</template>

<script>
export default {
  name: "TotalTodo",
  props: ["todos"],
  computed: {
    total() {
      return this.todos.length;
    },
    doneTotal() {
      const count = this.todos.reduce((accumulator, currItem) => {
        return accumulator + (currItem.done ? 1 : 0);
      }, 0);
      return count;
    },
    isAll: {
      get() {
        return this.total > 0 && this.total === this.doneTotal;
      },
      set(val) {
        // 删除props里的checkAllTodo,然后在这里改为$emit触发自定义事件checkAllEvent
        this.$emit("checkAllEvent", val);
      },
    },
  },
  methods: {
    clearAll() {
      // 删除props里的clearAllTodo,然后在这里改为$emit触发自定义事件clearAllEvent
      this.$emit("clearAllEvent");
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

# 5.5 全局事件总线

给一个组件绑定触发自定义事件,是可以在组件的外部内部进行的。这是因为,在内部可以通过this获取组件实例,在外部可以通过this.$refs.xxx来获取组件实例,又因为组件实例可以通过__proto__访问原型上的$on()$emit(),所以不管是在组件的外部内部都能进行绑定触发自定义事件。这一点非常重要,基本是这一章的核心思想了。

那如果我们准备一个所有组件都能访问到的公共组件,那其他组件是不是都可以使用这个公共组件的$on()$emit()了。

我们让A组件访问公共组件的$on(),给这个公共组件绑定一个自定义事件,回调函数留给A组件;再让B组件去访问公共组件的$emit()触发之前的同名自定义事件(携带新数据),这样B组件与A组件进行了单方面通信反之亦然A也能与B进行通信,那么实际上就是双方通信。那么再把范围扩大,那所有的组件之间都能进行互相通信了(任意组件间的通信)。

但现在要考虑这个公共组件是谁,要存在于哪:

  • 我们明确一点,组件实例的__proto__指向的原型对象,是继承自Vue.prototype的,这个在2.5 VueComponent这节说过。那么组件实例通过__proto__访问到原型对象,该原型对象又通过继承关系访问到Vue.prototype,就可以拿到$on()$emit()
  • 不要妄想直接通过Vue.prototype.$on()Vue.prototype.$emit()来绑定自定义事件,你绑定给谁?还得是一个实例。那是 Vue 实例vm还是某个组件实例vc呢?
  • 其实公共组件是 Vue 实例vm。使用$on()$emit()时,直接通过vm__proto__拿就可以了;如果是一个单独的新组件实例,链路会长一点。
  • 公共组件是谁搞清楚了,那这个vm存在哪了?以前是用const vm=来接收它,在脚手架项目是没有这样的,可以在main.js给 Vue 进行实例化时使用beforeCreate生命钩子,将this存到Vue.prototype,即Vue.prototype.$bus = this。这个$bus就是这一节的全局事件总线
import Vue from "vue";
import App from "./App.vue";

new Vue({
  el: "#app",
  render: (h) => h(App),
  components: {
    App,
  },
  beforeCreate() {
    // 安装全局事件总线,所有组件都能访问到$bus,并且所有组件都能使
    // 用到$bus的`$on()`和`$emit()`将自定义事件绑定到$bus身上
    Vue.prototype.$bus = this;
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

我们用SchoolStudent来演示平级组件间使用全局事件总线进行互相通信

App.vue

<template>
  <div class="outer">
    <h2>这里是父组件,本例不会涉及到父组件</h2>
    <School />
    <Student />
  </div>
</template>

<script>
import School from "./components/School.vue";
import Student from "./components/Student.vue";
export default {
  name: "App",
  components: {
    School,
    Student,
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

School.vue

<template>
  <div class="school">
    <h2>School-Info:{{ info }}</h2>
    <h3>学校:{{ name }}</h3>
    <h3>地址:{{ address }}</h3>
    <button @click.stop="changeInfo">修改Student组件展示信息</button>
  </div>
</template>

<script>
export default {
  name: "School",
  data() {
    return {
      info: "School这里还未被修改",
      name: "武汉大学",
      address: "武汉市武昌区珞珈山路",
    };
  },
  mounted() {
    // 给$bus绑定一个自定义事件changeSchoolEvent,将回调函数updateInfo留在了School组件本地
    this.$bus.$on("changeSchoolEvent", this.updateInfo);
  },
  beforeDestroy() {
    // 在本组件销毁前解绑之前绑定的自定义事件,否则当前组件销毁了,该事件一直存在
    this.$bus.$off("changeSchoolEvent");
  },
  methods: {
    updateInfo(info) {
      this.info = info;
    },
    changeInfo() {
      // 触发$bus上的changeStudentEvent事件,用以School对Student的通信
      this.$bus.$emit("changeStudentEvent", "由School组件修改了Student组件的展示信息");
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

Student.vue

<template>
  <div class="student">
    <h2>Student-Info:{{ info }}</h2>
    <h3>姓名:{{ name }}</h3>
    <h3>年龄:{{ age }}</h3>
    <button @click.stop="changeInfo">修改School组件展示信息</button>
  </div>
</template>

<script>
export default {
  name: "Student",
  data() {
    return {
      info: "Student这里还未被修改",
      name: "张三",
      age: 18,
    };
  },
  mounted() {
    // 给$bus绑定一个自定义事件changeStudentEvent,将回调函数updateInfo留在了Student组件本地
    this.$bus.$on("changeStudentEvent", this.updateInfo);
  },
  beforeDestroy() {
    // 在本组件销毁前解绑之前绑定的自定义事件,否则当前组件销毁了,该事件一直存在
    this.$bus.$off("changeStudentEvent");
  },
  methods: {
    updateInfo(info) {
      this.info = info;
    },
    changeInfo() {
      // 触发$bus上的changeSchoolEvent事件,用以Student对School的通信
      this.$bus.$emit("changeSchoolEvent", "由Student组件修改了School组件的展示信息");
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

总结拓展

  1. 全局事件总线是一种组件间通信的方式,适用于任意组件间的通信。
  2. 安装全局事件总线,在new Vue()时,使用beforeCreate生命钩子,在里面写上Vue.prototype.$bus = this
  3. 使用全局事件总线:
    1. 接收数据(绑定事件):A 组件想要接收数据,则在 A 组件中给$bus绑定自定义事件,事件的回调函数留在 A 组件自身。this.$bus.$on(事件名, 回调函数)
    2. 提供数据(触发事件):在其他组件使用this.$bus.$emit(事件名, 数据)进行传递数据。
  4. 最好在当前组件的beforeDestroy生命钩子中,用$off去解绑当前组件之前绑定的自定义事件。

# 5.6 TodoList 使用全局事件总线

5.4 TodoList 使用自定义事件这节里我们留下了孙组件这条线没有修改,我们可以让孙组件TodoItem与祖父组件App之间使用全局事件总线。当然,其他组件之间也是可以使用全局事件总线的,但是没有必要。

App.vue

<template>
  <div class="todo-container">
    <!-- 将`:addTodo="addTodo"`改为自定义事件 -->
    <AddTodo @addTodoEvent="addTodo"></AddTodo>
    <!-- 涉及到孙组件就不改动了 -->
    <TodoMain :todos="todos"></TodoMain>
    <!-- 将`:checkAllTodo="checkAllTodo"`和`:clearAllTodo="clearAllTodo"`改为自定义事件 -->
    <TotalTodo :todos="todos" @checkAllEvent="checkAllTodo" @clearAllEvent="clearAllTodo"></TotalTodo>
  </div>
</template>

<script>
import AddTodo from "./components/AddTodo.vue";
import TodoMain from "./components/TodoMain.vue";
import TotalTodo from "./components/TotalTodo.vue";

export default {
  name: "App",
  components: {
    AddTodo,
    TodoMain,
    TotalTodo,
  },
  mounted() {
    // 在全局事件总线上注册自定义事件,方便孙组件TodoItem进行触发事件,回调函数留在本组件
    this.$bus.$on("checkTodoEvent", this.checkTodo);
    this.$bus.$on("deleteTodoEvent", this.deleteTodo);
  },
  beforeDestroy() {
    // 绑定在全局事件总线上的自定义事件,记得在本组件销毁前要解绑自定义事件
    this.$bus.$off("checkTodoEvent");
    this.$bus.$off("deleteTodoEvent");
  },
  data() {
    return {
      todos: JSON.parse(localStorage.getItem("todos")) || [],
    };
  },
  watch: {
    todos: {
      deep: true,
      handler(value) {
        const jsonStr = JSON.stringify(value);
        localStorage.setItem("todos", jsonStr);
      },
    },
  },
  methods: {
    addTodo(item) {
      this.todos.unshift(item);
    },
    // 提供给孙组件TodoItem来触发的自定义事件的回调函数checkTodo
    checkTodo(id) {
      if (!id) return;
      for (let i = 0; i < this.todos.length; i++) {
        const item = this.todos[i];
        if (item.id === id) {
          item.done = !item.done;
          break;
        }
      }
    },
    // 提供给孙组件TodoItem来触发的自定义事件的回调函数deleteTodo
    deleteTodo(id) {
      if (!id) return;
      this.todos = this.todos.filter((item) => item.id !== id);
    },
    checkAllTodo(done) {
      this.todos.forEach((item) => (item.done = done));
    },
    clearAllTodo() {
      this.todos = this.todos.filter((item) => !item.done);
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76

TodoItem.vue

<template>
  <li>
    <label>
      <input type="checkbox" :checked="todoItem.done" @change="handleCheck(todoItem.id)" />
      <span>{{ todoItem.title }}</span>
    </label>
    <button class="btn btn-danger" @click="handleDelete(todoItem.id)">删除</button>
  </li>
</template>

<script>
export default {
  name: "TodoItem",
  props: ["todoItem"],
  methods: {
    handleCheck(id) {
      if (!id) return;
      // 触发祖父组件App绑定在全局事件总线上的自定义事件checkTodoEvent,并传递数据给父组件
      this.$bus.$emit("checkTodoEvent", id);
    },
    handleDelete(id) {
      if (!id) return;
      if (confirm("确实删除吗?")) {
        // 触发祖父组件App绑定在全局事件总线上的自定义事件checkTodoEvent,并传递数据给父组件
        this.$bus.$emit("deleteTodoEvent", id);
      }
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# 5.7 给 TodoList 追加编辑功能

这一节要给已有的 TodoList 追加编辑功能,让每一项可以进行单独的编辑,实现该功能有这些注意点:

  • 每一项的删除按钮旁要新增一个编辑按钮,还要加上一个输入框,在编辑状态下隐藏“编辑按钮”,并让输入框覆盖原来的文本。
  • TodoItem组件新增一个 data 数据,名为isEdit
  • 按下编辑按钮修改isEdit;输入完失去焦点也得修改isEdit,输入完得修改 todos 数据,得使用全局事件总线。
  • 按下编辑按钮得让输入框立即获得焦点,但是修改isEdit不会立马让页面更新,原因是浏览器要等修改数据这一轮 task执行完,才能去执行更新页面这一轮 task,那么focus必须得延迟到“更新页面这一轮 task*”之后再执行。Vue 给我们提供了$nextTick(callback),让 callback 在下一次更新完之后调用(下一轮 task 执行完之后调用 callback)。

App.vue

<template>
  <div class="todo-container">
    <AddTodo @addTodoEvent="addTodo"></AddTodo>
    <TodoMain :todos="todos"></TodoMain>
    <TotalTodo :todos="todos" @checkAllEvent="checkAllTodo" @clearAllEvent="clearAllTodo"></TotalTodo>
  </div>
</template>

<script>
import AddTodo from "./components/AddTodo.vue";
import TodoMain from "./components/TodoMain.vue";
import TotalTodo from "./components/TotalTodo.vue";

export default {
  name: "App",
  components: {
    AddTodo,
    TodoMain,
    TotalTodo,
  },
  mounted() {
    this.$bus.$on("checkTodoEvent", this.checkTodo);
    this.$bus.$on("deleteTodoEvent", this.deleteTodo);
    // 编辑后更新title
    this.$bus.$on("updateTodoEvent", this.updateTodo);
  },
  beforeDestroy() {
    this.$bus.$off("checkTodoEvent");
    this.$bus.$off("deleteTodoEvent");
    this.$bus.$off("updateTodoEvent");
  },
  data() {
    return {
      todos: JSON.parse(localStorage.getItem("todos")) || [],
    };
  },
  watch: {
    todos: {
      deep: true,
      handler(value) {
        const jsonStr = JSON.stringify(value);
        localStorage.setItem("todos", jsonStr);
      },
    },
  },
  methods: {
    addTodo(item) {
      this.todos.unshift(item);
    },
    checkTodo(id) {
      if (!id) return;
      for (let i = 0; i < this.todos.length; i++) {
        const item = this.todos[i];
        if (item.id === id) {
          item.done = !item.done;
          break;
        }
      }
    },
    deleteTodo(id) {
      if (!id) return;
      this.todos = this.todos.filter((item) => item.id !== id);
    },
    checkAllTodo(done) {
      this.todos.forEach((item) => (item.done = done));
    },
    clearAllTodo() {
      this.todos = this.todos.filter((item) => !item.done);
    },
    // 编辑后更新title
    updateTodo(id, title) {
      if (!id) return;
      for (let i = 0; i < this.todos.length; i++) {
        const item = this.todos[i];
        if (item.id === id) {
          item.title = title;
          break;
        }
      }
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83

TodoItem.vue

<template>
  <li>
    <label>
      <input type="checkbox" :checked="todoItem.done" @change="handleCheck(todoItem.id)" />
      <span v-show="!isEdit">{{ todoItem.title }}</span>
      <input
        type="text"
        v-show="isEdit"
        :value="todoItem.title"
        ref="inputTodo"
        @blur="inputBlur(todoItem.id, $event)"
      />
    </label>
    <button class="btn btn-danger" @click="handleDelete(todoItem.id)">删除</button>
    <button class="btn btn-edit" @click="handleEdit" v-show="!isEdit">编辑</button>
  </li>
</template>

<script>
export default {
  name: "TodoItem",
  props: ["todoItem"],
  data() {
    return {
      isEdit: false,
    };
  },
  methods: {
    handleCheck(id) {
      if (!id) return;
      this.$bus.$emit("checkTodoEvent", id);
    },
    handleDelete(id) {
      if (!id) return;
      if (confirm("确实删除吗?")) {
        this.$bus.$emit("deleteTodoEvent", id);
      }
    },
    // 处理编辑操作
    handleEdit() {
      // 这一轮走完都是只是修改了data数据,下一轮才是更新页面
      this.isEdit = true;
      // 如果不使用$nextTick,这一轮的input还未展示到页面,所以必须等到下一轮页面更新完,再让input获得焦点
      this.$nextTick(() => {
        this.$refs.inputTodo.focus();
      });
    },
    // 失去焦点
    inputBlur(id, e) {
      if (!id) return;
      this.isEdit = false;
      // 更新todos里的title
      this.$bus.$emit("updateTodoEvent", id, e.target.value);
    },
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

# 六、Vue 中的动画与过渡

# 6.1 动画

回顾一下 css 中原生的动画,animation: duration timing-function delay iteration-count direction fill-mode play-state name

  • duration:动画总时间
  • timing-function:动效方式
  • delay:延时触发时间
  • iteration-count:播放次数,可以带小数位
  • direction:播放方向,其值normal正向播放,reverse方向播放,alternate正向交替播放,alternate-reverse方向交替播放。
  • fill-mode:确定开始前或结束后是什么样式,其值none默认样式,forwards采用遇到的最后的一个关键帧,backwards采用遇到的第一个关键帧,both同时应用forwardsbackwards
  • play-state:动画是运行或暂停,runningpaused
  • name:@keyframes 动画名字。

我们使用animation搭配:class来实现动画的切换(就是修改 className),代码如下

<template>
  <div>
    <button @click="switchDisplay">显示/隐藏</button>
    <h2 :class="displayNow">你好啊啊啊啊啊</h2>
  </div>
</template>

<script>
export default {
  name: "AnimationTest",
  data() {
    return { displayNow: "display-enter" };
  },
  methods: {
    switchDisplay() {
      this.displayNow = this.displayNow === "display-leave" ? "display-enter" : "display-leave";
    },
  },
};
</script>

<style>
h2 {
  background-color: tomato;
}
/* 进入时的class */
.display-enter {
  animation: 3s ease 0.2s 1 normal both running aniEnter;
}
/* 出去时的class,注意切换动画不要用reverse,最好新建单独的离开@keyframes动画 */
.display-leave {
  animation: 3s ease 0.2s 1 normal both running aniLeave;
}
@keyframes aniEnter {
  from {
    transform: translateX(-100%);
  }
  to {
    transform: translateX(0);
  }
}
@keyframes aniLeave {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(-100%);
  }
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

上面这种方式在没有遇到v-showv-if之前还是挺好用的,一旦遇到v-showv-if,上面这种方式就无法奏效了。因为在元素被移除或者display:none,动画不会被执行。其实 Vue 给我们封装好了过渡和动画,只需要我们使用简单的 css 样式搭配类名就可以使用了。

Vue中的过渡与动画

<trnsition>标签用于包裹一个想要执行动画的元素或组件:

  • transition 只是一个标签,不占位置不会形成一个真正的元素,这个同 template 一样。
  • transition 标签可用name属性区分是哪个组件用什么动画样式,不至于混淆。
    • 如果没有name属性,那 css 中关联的动画样式类名就是默认.v-enter-active.v-leave-active
    • 如果name属性,比如 name 属性值是hello,那 css 中关联的动画样式名就.hello-enter-active.hello-leave-active
  • .v-enter-active {animation:xxx}进入时执行动画的样式,.v-leave-active {animation:xxx}离开时执行动画的样式。
  • 如果想在初始化就进行动画的执行,可以在 transition 标签里加上:appear="true"
<template>
  <div>
    <button @click="displayNow = !displayNow">显示/隐藏</button>
    <!-- Vue提供的transition,能让v-if和v-show加上animation动画 -->
    <transition name="hello" :appear="true">
      <h2 v-if="displayNow">你好啊啊啊啊啊</h2>
    </transition>
  </div>
</template>

<script>
export default {
  name: "AnimationTest",
  data() {
    return { displayNow: true };
  },
};
</script>

<style>
h2 {
  background-color: tomato;
}
/* 如果transition标签没有name属性,那它默认是.v-enter-active,如果有name那就是.name值-enter-active */
.hello-enter-active {
  animation: 3s ease 0.2s 1 normal both running aniEnter;
}
/* 如果transition标签没有name属性,那它默认是.v-leave-active,如果有name那就是.name值-leave-active */
.hello-leave-active {
  animation: 3s ease 0.2s 1 normal both running aniLeave;
}
@keyframes aniEnter {
  from {
    transform: translateX(-100%);
  }
  to {
    transform: translateX(0);
  }
}
@keyframes aniLeave {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(-100%);
  }
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

# 6.2 过渡

因为animation@keyframes,可以在@keyframes里决定开始结束状态,那么动画的类名也只涉及到.v-enter-active.v-leave-active。而这个transition没有@keyframes,那就需要单独定义开始结束状态

transition的类名除了需要.v-enter-active.v-leave-active以外(甚至一些情况下不需要这两个类名,直接将过渡信息写在元素本身上),还需要v-enterv-enter-tov-leave-tov-leave-to,分别表示“进入过程的开始状态”、“进入过程的结束状态”、“离开过程的开始状态”“离开过程的结束状态”。

<template>
  <div>
    <button @click="displayNow = !displayNow">显示/隐藏</button>
    <!-- Vue提供的transition,能让v-if和v-show加上animation动画 -->
    <transition name="hello" :appear="true">
      <h2 v-show="displayNow">你好啊啊啊啊啊</h2>
    </transition>
  </div>
</template>

<script>
export default {
  name: "AnimationTest",
  data() {
    return { displayNow: true };
  },
};
</script>

<style>
h2 {
  background-color: tomato;
}
/* 过渡信息写在这两个类名里,甚至可以直接放到h2元素里 */
.hello-enter-active,
.hello-leave-active {
  transition: 3s ease;
}
/* 进入过程的开始状态就是-100%,并且离开过程的结束状态也是-100% */
.hello-enter,
.hello-leave-to {
  transform: translateX(-100%);
}
/* 进入过程的结束状态就是0,并且离开过程的开始状态也是0 */
.hello-enter-to,
.hello-leave {
  transform: translateX(0);
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

# 6.3 多元素动画/过渡

多个元素进行动画/过渡,可以使用v-if控制只显示一个,也可以使用单独的容器将多个元素进行包裹再使用<transition>标签。还有一种是使用<transition-group>注意:同名元素 or 组件,得使用key来区分彼此。

v-if方式:

<template>
  <transition>
    <button v-if="isEditing" key="save">Save</button>
    <button v-else key="edit">Edit</button>
  </transition>
</template>
1
2
3
4
5
6

<transition-group>方式:

<template>
  <div>
    <button @click="displayNow = !displayNow">显示/隐藏</button>
    <!-- 简单修改了上一节的模板即可 -->
    <transition-group name="hello" :appear="true">
      <!-- 使用key来区分 -->
      <h2 v-show="displayNow" key="1">你好啊啊啊啊啊</h2>
      <h2 v-show="!displayNow" key="2">我不好啊啊啊啊</h2>
    </transition-group>
  </div>
</template>
1
2
3
4
5
6
7
8
9
10
11

# 6.4 集成第三方动画库

推荐[Animate.css](https://animate.style/), 使用npm i animate.css进行安装,安装之后引入到项目里import "animate.css"`。

然后选择一个要使用动画<transition><transition-group>,在标签里添加name="animate__animated animate__bounce",如果不是 Vue 项目,这个name换成class

再就是使用 Vue 给我们提供的 6 个自定义过渡的类名,是专门用来配合第三方动画库来使用的。这 6 个和6.2 过渡这节里的类名极其相似,只是将v-换成class放到了末尾。

  • enter-active-class:进入过程的动效
  • leave-active-class:离开过程的动效
  • enter-class:进入过程的开始状态
  • enter-to-class:进入过程的结束状态
  • leave-class:离开过程的开始状态
  • leave-to-class:离开过程的结束状态
<template>
  <div>
    <button @click="displayNow = !displayNow">显示/隐藏</button>
    <!-- 引入第三方动画库 -->
    <transition
      :appear="true"
      name="animate__animated animate__bounce"
      enter-active-class="animate__backInDown"
      leave-active-class="animate__backOutRight"
    >
      <h2 v-show="displayNow">你好啊啊啊啊啊</h2>
    </transition>
  </div>
</template>

<script>
import "animate.css";
export default {
  name: "AnimationTest",
  data() {
    return { displayNow: true };
  },
};
</script>

<style>
h2 {
  background-color: tomato;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

我们给 TodoList 案例加上第三方动画库,在新增和删除时,让每一项的进入和离开都具有动画。

<template>
  <ul class="todo-main">
    <!-- 要记得引入import "animate.css",每一项本就有key了   -->
    <transition-group
      name="animate__animated animate__bounce"
      enter-active-class="animate__backInRight"
      leave-active-class="animate__backOutLeft"
    >
      <TodoItem v-for="todo in todos" :key="todo.id" :todoItem="todo"></TodoItem>
    </transition-group>
  </ul>
</template>
1
2
3
4
5
6
7
8
9
10
11
12

总结拓展

  1. Vue 给我们封装好了过渡与动画,因为原生的 css 过渡和动画你不好掌握在 Vue 项目中什么情况下能使用。
  2. 在插入、更新和移除 DOM 元素时,在合适的时候给元素加样式类名。
  3. 准备好样式:
    1. 元素进入时的样式:
      • v-enter:进入的起点(进入过程的开始状态)
      • v-enter-to:进入的终点(进入过程的结束状态)
      • v-enter-active:进入中(进入过程中使用动效 animation 或 transition)
    2. 元素离开时的样式:
      • v-leave:离开的起点(离开过程的开始状态)
      • v-leave-to:离开的终点(离开过程的结束状态)
      • v-leave-active:离开中(离开过程中使用动效 animation 或 transition)
  4. 使用<transition>包裹需要过渡/动画的元素,并配置 name 属性。
  5. 若多个元素需要过渡/动画,则需要使用<transition-group>,且同名元素要指定一个key="xxx"

# 七、内容分发——插槽

# 7.1 默认插槽

复用组件时,我们可能需要使用相同的数据来呈现不同的页面展示形式,你可能会使用 type 搭配v-ifv-else-ifv-else来实现,例如下面这个例子:

App4.vue

<template>
  <div class="outer">
    <!-- listData数据相同,传入不同的type来展示不同形状的的页面结构 -->
    <Category title="美食" :listData="foods" :type="1"></Category>
    <Category title="美食" :listData="foods" :type="2"></Category>
    <Category title="美食" :listData="foods" :type="3"></Category>
  </div>
</template>

<script>
import Category from "./components/Category.vue";
export default {
  name: "App4",
  components: { Category },
  data() {
    return {
      foods: ["火锅", "烧烤", "麻辣烫"],
    };
  },
};
</script>
<style scoped>
.outer {
  display: flex;
  justify-content: space-around;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

Category.vue

<template>
  <div class="inner">
    <h2 class="title">{{ title }}</h2>
    <!-- type搭配v-if、v-else-if和v-else来决定展示什么样的自定义内容 -->
    <ul v-if="type === 1">
      <li v-for="(item, index) in listData" :key="index">{{ item }}</li>
    </ul>
    <ol v-else-if="type === 2">
      <li v-for="(item, index) in listData" :key="index">{{ item }}</li>
    </ol>
    <div v-else>
      <h4 v-for="(item, index) in listData" :key="index">{{ item }}</h4>
    </div>
  </div>
</template>

<script>
export default {
  name: "Category",
  props: ["title", "listData", "type"],
};
</script>

<style scoped>
.inner {
  width: 25%;
  height: 350px;
  background-color: darkcyan;
}
.title {
  text-align: center;
  background-color: darkorange;
}
h4 {
  text-align: center;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

我们可以使用默认插槽来优化修改上面的代码。插槽的作用就是将父组件传来自定义内容(自定义结构)插入到子组件的<slot>里(内容分发)。“自定义内容”,可以有普通文本,也可以有自己编写的散乱的模板代码,也可以有<template>包裹的模板代码,也可以有自定义组件

<!-- 在子组件调用标签里(不是子组件定义处),写上自定义内容,
最后内容会被分发到子组件内部(子组件定义处)里面的插槽处(插到插槽里) -->
<navigation-link>
  <!-- 自定义内容开始 -->
  <!-- span元素和“hello”形成的是散乱的模板代码 -->
  <span class="fa">{{name,}}</span>
  hello
  <!-- 自定义内容结束 -->
</navigation-link>
<navigation-link>
  <!-- 自定义内容开始 -->
  <!-- 自定义组件font-awesome-icon,它也会被分发到navigation-link组件内部 -->
  <font-awesome-icon name="user"></font-awesome-icon>
  hello
  <!-- 自定义内容结束 -->
</navigation-link>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

修改开头的例子:

  • 先将子组件内部的 type 和v-if适配代码全部去掉,然后使用插槽<slot></slot>占住之前适配代码所在位置,最后就等待父组件传来自定义内容的插入。相当于在组件内部挖了一个坑,等着父组件根据不同的情况将自定义内容传过来填坑
  • 处理好子组件内部后,在父组件这边,需要将自定义内容(自定义结构),放到组件里的子组件调用标签内。具体是先将<TodoList/>打开成<TodoList></TodoList>,再把自定义内容放入<TodoList></TodoList>之间

以上两步过后,Vue 在解析组件模板时,也会将<TodoList></TodoList>之间的自定义结构进行解析;到了组件模板开始解析时,会把刚刚解析好的内容放到<slot></slot>。这就是使用不同的自定义内容,展示到子组件里,也叫做内容分发。上面例子经过插槽优化修改后的代码如下:

App4.vue

<template>
  <div class="outer">
    <Category title="美食">
      <!--将不同的自定义结构(ul-li)插入到子组件的插槽里,其实数据都是一样的 -->
      <ul>
        <li v-for="(item, index) in foods" :key="index">{{ item }}</li>
      </ul>
    </Category>
    <Category title="美食">
      <!--将不同的自定义结构(ol-li)插入到子组件的插槽里,其实数据都是一样的 -->
      <ol>
        <li v-for="(item, index) in foods" :key="index">{{ item }}</li>
      </ol>
    </Category>
    <Category title="美食">
      <!--将不同的自定义结构(ul-h4)插入到子组件的插槽里,其实数据都是一样的 -->
      <div>
        <h4 v-for="(item, index) in foods" :key="index">{{ item }}</h4>
      </div>
    </Category>
  </div>
</template>

<script>
import Category from "./components/Category.vue";
export default {
  name: "App4",
  components: { Category },
  data() {
    return {
      foods: ["火锅", "烧烤", "麻辣烫"],
    };
  },
};
</script>
<style scoped>
.outer {
  display: flex;
  justify-content: space-around;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

Category.vue

<template>
  <div class="inner">
    <h2 class="title">{{ title }}</h2>
    <!-- 承接父组件传来的自定义内容。它属于默认插槽,也就是没有name -->
    <slot>我是插槽的后备内容,如果有自定义内容传过来,此处就会被它替代</slot>
  </div>
</template>

<script>
export default {
  name: "Category",
  props: ["title"],
};
</script>

<style scoped>
.inner {
  width: 25%;
  height: 350px;
  background-color: darkcyan;
}
.title {
  text-align: center;
  background-color: darkorange;
}
h4 {
  text-align: center;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

看完上面例子,是组件将不同的自定义结构传递给了组件,只是不是通过以往的 props 传参方式,而是通过在子组件设置插槽的方式进行插入自定义结构的。只要区分一个是传,一个是传自定义结构(非值,一般是模板代码或者组件)。

还有两个注意点:

  • 如果没有在子组件里使用 slot 标签,也就是没有设置插槽地点,父组件的自定义内容就会被抛弃
  • 如果在子组件里使用了 slot 标签,但是没有在父组件自定义内容,那么 slot标签体的内容会显示在子组件里,它是一个后备内容(与函数的形参默认值类似)。

# 7.2 具名插槽

默认插槽没有带name属性(其实默认带了,即name="default"),一般都默认将自定义 html 内容插入到这个默认插槽里。其实是可以使用多个插槽的,需要在<slot>里使用name="xxx"属性进行区分(定义插槽名),这种插槽叫做具名插槽具名插槽主要是为了在组件内部使用多个插槽,将不同的内容分发到子组件内不同的位置。

在子组件里的插槽使用name="xxx"进行区分,而在父组件的自定义内容这边:

  1. 如果自定义内容没有使用<template>进行包裹也不是自定义组件(普通元素),那就使用slot="xxx"加到普通元素上,xxx 是插槽名。
  2. 如果自定义内容使用了<template>进行包裹,或者是一个组件定组件,那就使用v-slot:xxx加到<template>标签或者自定义组件标签里,xxx 是插槽名。
  3. 注意v-slot只能添加在<template>或者自定义组件标签里,不能用在普通的元素上
  4. v-slot:xxx,是可以进行缩写的,即v-slot:xxx缩写为#xxx,这个 xxx 是插槽名。
  5. 那默认插槽呢?你都没名字,再用slot="xxx"v-slot:xxx就没有意义了。如果你非要加slot="default"v-slot:default#default,就可以加到子组件调用标签里(子组件也是组件,所以可用v-slot)。就不是加到自定义内容本身了,这容易引起歧义,最好不要这么做!!!

App4.vue

<template>
  <div class="outer">
    <!-- 这里还可以是#default,其实没什么必要加。因为Category内部自定义内容如果不
    加slot="xxx"或#xxx,就表示Category内部整个自定义内容都插入到默认插槽了 -->
    <Category title="美食" slot="default">
      <!-- 普通元素就加slot="xxx",比如这里可以是slot="content" -->
      <img src="https://t7.baidu.com/it/u=760837404,2640971403&fm=193&f=GIF" />
      <!-- 普通元素就加slot="xxx",比如这里可以是slot="footer" -->
      <a href="http://">更多美食</a>
    </Category>

    <Category title="游戏">
      <!-- 使用了模板标签,那就用v-slot: content,还可缩写为#content。将它插入到名为content的插槽里 -->
      <template #content>
        <ul>
          <li v-for="(item, index) in games" :key="index">{{ item }}</li>
        </ul>
        <div class="foot">
          <a href="http://">单机游戏</a>
          <a href="http://">网络游戏</a>
        </div>
      </template>
    </Category>

    <Category title="电影">
      <!-- 没有用模板语法也不是自定义组件,而是普通元素,那就加slot="content"。将它插入到名为content的插槽里 -->
      <video
        slot="content"
        controls
        src="http://pgc.qcdn.xiaodutv.com/1491690018_136852136_2021022300080220210223005108.mp4"
      ></video>
      <!-- 没有用模板语法也不是自定义组件,而是普通元素,那就加slot="foot"。将它插入到名为foot的插槽里 -->
      <div class="foot" slot="footer">
        <a href="http://">经典</a>
        <a href="http://">热门</a>
        <a href="http://">推荐</a>
      </div>
      <!-- 没有用模板语法也不是自定义组件,而是普通元素,那就加slot="foot"。将它插入到名为foot的插槽里 -->
      <!-- 可以将多个内容放到同一个插槽里,不会被覆盖,只会进行追加。追加到footer插槽里了。 -->
      <h4 slot="footer">欢迎来到影院</h4>
    </Category>
  </div>
</template>

<script>
import Category from "./components/Category.vue";
export default {
  name: "App4",
  components: { Category },
  data() {
    return {
      foods: ["火锅", "烧烤", "麻辣烫"],
      games: ["王者荣耀", "英雄联盟", "绝地求生"],
      movies: ["头文字D", "不能说的秘密", "我不是药神"],
    };
  },
};
</script>
<style scoped>
.outer,
.foot {
  display: flex;
  justify-content: space-around;
}
img,
video {
  width: 100%;
}
h4 {
  text-align: center;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72

Category.vue

<template>
  <div class="inner">
    <h2 class="title">{{ title }}</h2>
    <!-- 多个插槽使用name进行区分,这叫做具名插槽 -->
    <slot name="default">我是content插槽的后备内容</slot>
    <slot name="content">我是content插槽的后备内容</slot>
    <slot name="footer">我是footer插槽的后备内容</slot>
  </div>
</template>

<script>
export default {
  name: "Category",
  props: ["title", "listData"],
};
</script>

<style>
.inner {
  width: 25%;
  height: 350px;
  background-color: darkcyan;
}
.title {
  text-align: center;
  background-color: darkorange;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

父组件可以使用多个同插槽名的自定义内容,最终会将这些同名的自定义内容放到了同一个插槽里面(比如上例里的第三个Category组件)。如果想把多个自定义内容放一起但又不想多包一层 div,可以使用<template #xxx>模板进行包裹((比如上例里的第二个Category组件))。

# 7.3 作用域插槽

我们可以继续观察默认插槽里的例子,那个listData 数据是在组件里的,如果实际开发中数据就存在于组件里,并且因为历史原因还不容易移到父组件上去的话,这该如何进行优化修改呢?

插槽还有一种作用于插槽,可以从组件插槽这里将数据传递组件的自定义内容处,这个就比较奇特了。父组件将“结构”传给子组件之前反而子组件将你需要的数据反传过来了。这种方式称为插槽 prop,这和父子 props 方式类似,但插槽 prop 最后并没有将数据添加到组件实例上,它的作用域仅限于那个自定义内容那块范围(name 那块的<template>内部),这点得与 props 区分开。

作用域插槽的具体使用:

  1. <slot>标签里使用类似 props 的传参方式,也就是使用属性(静态传递或动态绑定后传递都可)。
  2. 父组件里自定义内容处():
    1. 无论是默认插槽还是具名插槽,只要自定义内容是<template>包裹或者是自定义组件,那就可以在<template>标签或者自定义组件调用标签里,加上#xxx="yyy",xxx 是插槽名(name值),yyy 是传参对象(通过插槽 prop传来的所有参数集中在该对象上)。
    2. 如果只是默认插槽,自定义内容也没有用<template>包裹也不是自定义组件,那么可以将#default="yyy"直接加到子组件调用标签里,yyy 是传参对象
    3. 没有普通元素的场景,无法将#xxx="yyy"加到普通元素上,这是作用域插槽这不是具名插槽(加作用域和加名字不一样)。这是因为作用域插槽除了刚刚第二种场景以外,就只能用在<template>这种场景下了。
    4. 我们并没有介绍name="xxx" scope="yyy"的写法,这是2.6 版本以前的写法,除非遇到老项目要维护,不推荐使用老写法了。这种老写法也不能加到普通元素上了,老老实实使用<template>方式吧!!!
  3. 子组件插槽传参的话,比如这边是<slot :foods="foods" name="xxx">,那边得是<template #xxx="yyy">。注意,yyy 并不就是 foods 了,这个 foods 传过来后它只是 yyy 上的一个属性,也就是说使用yyy.foods才能取到想要值。这个yyy传参对象,是包含了所有的插槽传参属性,所以你还能使用对象的解构

将第一节的例子进行修改,把listData 数据放到 Category 组件内,使用插槽 prop将数据传给自定义内容,自定义内容使用#xxx="yyy"进行接收。

App4.vue

<template>
  <div class="outer">
    <Category title="美食">
      <!-- 一般场景,而且一般都是具名插槽。在template标签里加上了`#default="param"` -->
      <template #default="param">
        <ul>
          <!-- foods只是param对象里的一个属性 -->
          <li v-for="(item, index) in param.foods" :key="index">{{ item }}</li>
        </ul>
      </template>
    </Category>
    <!-- 该场景仅限于默认插槽。在子组件Category标签里加上了`#default="param"`,这是个特例,域插槽几乎只用在template上 -->
    <Category title="美食" #default="param">
      <!-- 千万不在ol标签里加上`#default="param"`,一是因为v-slot之能用在template上,二是因为就算你使
      用老写法`slot="default" scope="param"`也不行,因为作用域插槽几乎只用在template上,不会用在普通元素上 -->
      <ol>
        <!-- foods只是param对象里的一个属性 -->
        <li v-for="(item, index) in param.foods" :key="index">{{ item }}</li>
      </ol>
    </Category>
    <Category title="美食">
      <!-- 一般场景,而且一般都是具名插槽。在template标签里加上了`#default="param"` -->
      <template #default="param">
        <div>
          <!-- foods只是param对象里的一个属性 -->
          <h4 v-for="(item, index) in param.foods" :key="index">{{ item }}</h4>
        </div>
      </template>
    </Category>
  </div>
</template>

<script>
import Category from "./components/Category.vue";
export default {
  name: "App4",
  components: { Category },
};
</script>
<style scoped>
.outer {
  display: flex;
  justify-content: space-around;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

Category.vue

<template>
  <div class="inner">
    <h2 class="title">{{ title }}</h2>
    <!-- 这是默认的作用域插槽,将数据传给了各个自定义内容处 -->
    <slot :foods="foods"></slot>
  </div>
</template>

<script>
export default {
  name: "Category",
  props: ["title"],
  // 对比默认插槽,数据从父组件移到了子组件内部
  // 如果外界想用,就通过类似props方式传出去
  data() {
    return {
      foods: ["火锅", "烧烤", "麻辣烫"],
    };
  },
};
</script>

<style scoped>
.inner {
  width: 25%;
  height: 350px;
  background-color: darkcyan;
}
.title {
  text-align: center;
  background-color: darkorange;
}
h4 {
  text-align: center;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

总结拓展

  1. 插槽的作用:让父组件可以向子组件指定位置插入自定义内容(将自定义结构传到子组件内部),也是一种组件间通信的方式,适用于父===>子。

  2. 插槽分类:默认插槽、具名插槽、作用于插槽。

  3. 默认插槽的使用:

    <!-- 父组件,将自定义内容传递给子组件,也就是插入到子组件插槽处 -->
    <Category>
      <div>这个div就是一个自定义内容(自定义结构)</div>
    </Category>
    <!-- 子组件,使用插槽<slot>来承接自定义内容 -->
    <template>
      <div>
        <div>子组件其他内容</div>
        <!-- 默认插槽没有名字,其实有name="default"的默认名字 -->
        <slot>这是一个默认插槽</slot>
      </div>
    </template>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
  4. 具名插槽的使用:

    <!-- 父组件里的自定义内容,使用slot="xxx"区分 -->
    <Category>
      <!-- 普通元素的场景,使用slot="center" -->
      <div slot="center">这个div就是一个自定义内容(自定义结构)</div>
      <!-- 用<template>进行了包裹的场景,那就在template标签里使用#footer(全写是v-slot:footer) -->
      <template #footer>
        <div>这个div就是一个自定义内容(自定义结构)</div>
      </template>
    </Category>
    <!-- 子组件,使用name="xxx",主要是为了分区 -->
    <template>
      <div>
        <div>子组件其他内容</div>
        <!-- 具名插槽,名字是"center" -->
        <slot name="center">这是一个具名插槽</slot>
        <!-- 具名插槽,名字是"footer" -->
        <slot name="footer">这是一个具名插槽</slot>
      </div>
    </template>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
  5. 作用域插槽的使用(关键数据在子组件中):

    <!-- 父组件里的自定义内容,如果在具名插槽下就必须使用template进行包裹 -->
    <Category>
      <template #footer="param">
        <ul>
          <!-- param是参数对象,foods只是它其中的一个属性 -->
          <li v-for="(item, index) in param.foods" :key="index">{{ item }}</li>
        </ul>
      </template>
    </Category>
    <!-- 父组件里的自定义内容,如果是默认插槽可以将#default="param"写到子组件标签里 -->
    <Category #default="param">
      <!-- 可以改成上面那种,使用默认插槽式的作用域插槽很容易让人犯糊涂 -->
      <ol>
        <!-- param是参数对象,games只是它其中的一个属性 -->
        <li v-for="(item, index) in param.games" :key="index">{{ item }}</li>
      </ol>
    </Category>
    <!-- 子组件,使用了插槽porps对父组件的自定义内容进行了传参 -->
    <template>
      <div>
        <div>子组件其他内容</div>
        <slot :games="games">这是一个作用域插槽插槽</slot>
        <slot name="footer" :foods="foods">这是一个作用域插槽插槽</slot>
      </div>
    </template>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25