我是怎么从 React 过渡到 Vue2 的?

2,209 阅读18分钟

前言

之前学习或工作经历中都是 React 技术栈相关的,现来到新公司后需要使用 Vue2 相关技术栈维护项目,开发需求。大概花了一周时间左右刷了刷 Vue2 的官方文档,现在为了加强自己在 Vue2 使用上的熟练度,也为了防止因为以后 React 不太常用但是特定时刻又要切换回去的时候能快速记忆起用法,于是就有了这篇讲解 Vue2 与 React 在基础使用上的对应关系的文章。

强调一下,这篇文章不会在两个框架的原理上有过多深入(我还没读过源码 😂),仅仅是从我们常规开发中需要用到的实现上做了比对,简单来说就是,我在 React 中的实现如何用 Vue2 去实现

设计理念

一个庞大而复杂的项目拥有分工明确的代码结构是很重要的,这对于项目维护具有非常重要的意义,所以 React 和 Vue 都推崇组件化的方式去组织我们的项目,就像一台完整的计算机一样,打散开来各个模块都可以独立设计、开发、互不耦合,最后按照大家统一的协议去设计好接口,最终才能组装成一台强大、完整的计算机。

但是在整体的写法上,两个框架的设计理念是不太一样的:

JSX

React 中只有一个语法糖,那就是 JSX,将结构与执行逻辑以及表现都融入到 JacaScript 中,这也就是为什么说 React 相比起来较为灵活的原因。这种 all in js 的方式有一定的弊端,会让 html 与 js 强耦合,导致组件内代码混乱,不利于维护。但是另一方面,这样的形式能在类型提示、自动检查,以及调试时候能精确跳转到定义,这种开发体验在可维护性上又弥补了许多。

template 模板

Vue 拥抱了比较传统且经典的思想,将 html、css、js 分离开来,这就意味着开发者在编写代码时会将结构、执行逻辑和表现分开进行,这对于项目的可维护性上有很大的提升。但是在 Vue 中我们使用 template 模板,并借助提供的 v-ifv-showv-for 等语法糖去编写代码时,在类型提示、定义跳转等等方面又是非常不友好的,这对于项目维护又是一个减分项。

一个例子 🌰

现在我们有一个简单的场景,根据某个状态来决定渲不渲染某个“小”组件,这个状态可随按钮点击进行布尔值切换。 11.gif

在 React 中这样写:

import React, { useState } from "react";

function App() {
  const [show, setShow] = useState(true);

  const renderContent = () => {
    return (
      <div>
        Contents are all here...
        <span>Could be more complex</span>
      </div>
    );
  };

  return (
    <div className="App">
      <h1>Do you want to show the content below?</h1>
      <button onClick={() => setShow(!show)}>Click</button>
      {show && renderContent()}
    </div>
  );
}

export default App;

我们使用 JSX 语法 { show && ...} 去判断后面的渲染逻辑是否执行, 并且将渲染逻辑单独抽离了出来(即 renderContent ,没有直接在后面写渲染,这种抽离在我以前的开发经验中是很常见的,一是为了结构复用,比如当前文件内其他地方也用到了这种渲染结构,但是该“小组件”的体量又不足以让我去单独创建一个 jsx 文件来写成一个独立的组件;二是为了保持 return 中的代码简洁。

但是随着需求的持续迭代,当前这个 App 组件会变得无比臃肿,比如充斥大量类似 renderContent 这种渲染结构散落在组件内。假如你现在是一个接手该项目的人,你会发现,你根据已展现的页面结构来对应代码中的渲染结构,会非常累!!!以前维护过一个页面内写了几千行代码的 jsx 文件,各种渲染逻辑、执行逻辑大量穿插在这个页面的各处,我当时直接 emo 了。

在 Vue 中使用 template 模板我们可以这样写:

<template>
  <div id="app">
    <h1>Do you want to show the content below?</h1>
    <button @click="handleBtnClick">Click</button>
    <div v-show="show">
      Contents are all here...
      <span>Could be more complex</span>
    </div>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      show: true,
    };
  },
  methods: {
    handleBtnClick() {
      this.show = !this.show;
    },
  },
};
</script>

可以看到使用模板去写组件时,你的渲染结构全部都在 template 模板内,页面与代码结构相一致,这对于初步接手的开发者来说是很友好的。另外,如果想要在该组件内复用带 v-show 这部分的渲染逻辑,将会被被强迫封装为另一个组件。在 React 中不这么做是因为实在是太自由了,大多数时候大多数人不想这么麻烦。

使用 template 模板写法的弊端就是,写在指令后的内容都是以字符串形式去书写的,定义跳转这种实用的功能被掐的死死的,似乎可以和 TypeScript 配合达到,但是听说 Vue2 和 Ts 配合蛮困难的。

组件结构

React 使用 .jsx 文件来定义组件,一般样式是单独引入的文件,在该组件内强耦合了 HTML 和 JS,使用 {} 来解析表达式,写法如下:

// OneComponent.jsx
import React, { useState } from "react";
// import './style.css';

function OneComponent() {
  const [content, setContent] = useState("I am one of useless component.");

  return <div>{content}</div>;
}

export default OneComponent;

使用组件:

// App.jsx
import OneComponent from "./OneComponent";

function App() {
  return (
    <div>
      One component below:
      <OneComponent />
    </div>
  );
}

Vue 使用 .vue 文件来定义组件,在此文件中同时编写 HTML、CSS、JS,template 内使用 {{}} 来解析表达式或值,写法如下:

// OneComponent.vue // 结构(html)
<template>
  <div>{{ content }}</div>
</template>

// 执行逻辑(js)
<script>
export default {
  name: "OneComponent",
  data() {
    return {
      content: "I am one of useless component.",
    };
  },
};
</script>

// 表现(css)
<style scoped></style>

使用组件:

// App.vue
<template>
  <div>
    One component below:
    <one-component />
  </div>
</template>

<script>
import OneComponent from "./OneComponent";

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

数据管理

React 和 Vue 都是单向数据流,父组件的数据可向下流入子组件,反过来则不行。组件的数据来源一般包括两个部分,一个是通过 props 传入的,另一个是自身的数据。

react

在 React 中支持向下传递静态动态prop,静态 prop 一般直接传字符串。

props

函数组件获取 props 的方式如下:

function Student(props) {
  return <h1>My name is {props.name}</h1>;
}

const element = <Student name="vortesnail" />;

动态 prop 可以这也写:

<Student name={name} age={1} isMarried={false} isHandsom />

state

React 16.8 以前的 class component 使用 state 来管理组件内的数据状态,16.8 后的 hooks 使函数式组件也有了管理 state 的能力。

useState 返回一个 state,以及更新 state 的函数。如果新的 state 需要使用到上一次的 state ,可以传递一个函数给 setState 。该函数第一个参数接收的即为上一次的 state ,处理并返回一个更新后的值。

import React, { useState } from "react";

function Counter({ initialCount }) {
  const [count, setCount] = useState(initialCount);

  return (
    <>
      Count: {count}
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => setCount((prevCount) => prevCount - 1)}>-</button>
      <button onClick={() => setCount((prevCount) => prevCount + 1)}>+</button>
    </>
  );
}

vue

在 Vue 中同样支持静态和动态的 prop 传递,不过在动态传递的情况下,要用指令 v-bind ,简写为 :

props

静态 prop 一般传递字符串,获取 props 方式如下:

<template>
  <h1>My name is {{ name }}, I'm {{ age }} years old.</h1>
</template>

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

传递动态 prop 就不太一样,需要用到 v-bind

<student :name="name" :age="1" />
;

在 Vue 中对 prop 中可以做类型约束,比如:

props: { name: String, age: { type: Number, default: 18 } }

但是在 React 中要借助 prop-types 这个库才行(使用 TypeScript 只是编译时检查)。

data

Vue 中组件内部的数据状态由 data 来管理,当一个组件被定义,data 必须声明为返回一个初始数据对象的函数。

<script>
export default {
  name: "OneComponent",
  data() {
    return {
      name: "vortesnail",
      age: 12,
    };
  },
};
</script>

可直接通过 vue 实例来对状态进行修改:

methods: { changeName() { this.name = 'vortesnail2'; } }

class 和 style

classstyle 的写法上,React 和 Vue 之间有比较大的差异。

react

React 中使用 className 关键字来代替真实 dom 中的 class 属性。

className

React 中 className 一般传字符串常量或者字符串变量,不支持传递数组或者对象。

import React, { useState } from "react";

function App() {
  const [show, setShow] = useState(true);
  const [active, setActive] = useState(true);

  return (
    <div
      className={`app ${show ? "show" : ""} ${active ? "active" : ""}`}
    ></div>
  );
}

export default App;

React 里面直接采用 JS 的模板字符串语法,样式太多的情况下可以采用 classnames 这个包,优雅传递各种状态,使用非常简单:

classNames("foo", "bar"); // => 'foo bar'
classNames("foo", { bar: true }); // => 'foo bar'
classNames({ "foo-bar": true }); // => 'foo-bar'
classNames({ "foo-bar": false }); // => ''
classNames({ foo: true }, { bar: true }); // => 'foo bar'
classNames({ foo: true, bar: true }); // => 'foo bar'

// lots of arguments of various types
classNames("foo", { bar: true, duck: false }, "baz", { quux: true }); // => 'foo bar baz quux'

// other falsy values are just ignored
classNames(null, false, "bar", undefined, 0, 1, { baz: null }, ""); // => 'bar 1'

style

React 中 style 接收一个对象

const someStyles = {
  color: lightyellow,
  background: lightblue,
  fontSize: "12px",
};

function App() {
  return <div style={someStyles}>Colorful world!</div>;
}

vue

与 React 不同的是,Vue 中对 classstyle 做了功能上的增强,可以传字符串数组对象。 另外,v-bind:class 还会与 class 进行合并,v-bind:style 还会与 style 进行合并。

class

  1. 绑定字符串:
<div class="app">hello world!</div>
  1. 绑定对象:
<template>
  <div class="app" :class="{ show: isShow, active: isActive }">
    hello world!
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      isShow: true,
      isActive: false,
    };
  },
};
</script>

<style>
.app {
  background-color: #fff;
}
.show {
  display: block;
}
.active {
  color: blue;
}
</style>

真实的 dom 结构渲染出来如下:

<div class="app show">hello world!</div>
  1. 绑定数组:
<template>
  <div class="app" :class="[showClass, activeClass]">hello world!</div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      showClass: "show",
      activeClass: "active",
    };
  },
};
</script>

<style>
.app {
  background-color: #fff;
}
.show {
  display: block;
}
.active {
  color: blue;
}
</style>

真实的 dom 结构渲染出来如下:

<div class="app show active">hello world!</div>
  1. class 能直接作为组件的属性传递给组件内部最外层元素:
Vue.component('my-component', { template: '
<p class="origin">hello world!</p>
' })

使用时额外添加 class

<my-component class="extra"></my-component>

真实的 dom 结构渲染出来如下:

<p class="origin extra">hello world!</p>

style

  1. 传对象:
<template>
  <div :style="{ color: activeColor, fontSize: fontSize + 'px' }">
    hello world!
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      activeColor: "green",
      fontSize: 16,
    };
  },
};
</script>

真实的 dom 结构渲染出来如下:

<div style="color: green; font-size: 16px;">hello world!</div>
  1. 传数组:
<template>
  <div :style="[baseStyles, overridingStyles]">hello world!</div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      baseStyles: {
        fontSize: "20px",
        color: "green",
      },
      overridingStyles: {
        fontSize: "16px",
      },
    };
  },
};
</script>

真实的 dom 结构渲染出来如下:

<div style="font-size: 16px; color: green; height: 80px;">hello world!</div>

条件渲染

条件渲染就是根据某一条件去判断是否渲染某个内容。

react 实现

在 React 中常使用与运算符 && 、三目运算符 ? : 、判断语句 if...else 来实现条件渲染。

1. 与(&&)运算符

与运算符 && ,左边值为真时,就会渲染右边的内容。

return <div>{show && <div>Content here.</div>}</div>;

2. 三目运算符(? :

和 js 中语法一样,条件满足就渲染 : 前面的内容,反之渲染后面。

return (
  <div>
    {renderFirst ? <div>First content.</div> : <div>Second content.</div>}
  </div>
);

3. 多重判断语句

return 语句中不要写太多的条件嵌套判断,比如用三目运算符尽量不要使用超过一层,不然代码会变得非常难读,所以一般我们会把这种多重判断渲染内容的放到外部函数去做,函数内通过 if...elseswitch case 去做筛选。

import React, { useState, Fragment } from "react";

function App() {
  const [order, setOrder] = useState("first");

  const renderContent = (order) => {
    if (order === "first") {
      // Frament 不会有实际 DOM 生成,可以多块内容分组
      return (
        <Fragment>
          <div>First content.</div>
          <div>Extra content of first part.</div>
        </Fragment>
      );
    } else if (order === "second") {
      return <div>Second content.</div>;
    } else if (order === "third") {
      return <div>Third content.</div>;
    } else {
      return null;
    }
  };

  return <div>{renderContent(order)}</div>;
}

export default App;

vue 实现

在 vue 中实现条件渲染只需要使用指令 v-ifv-else-ifv-else 即可。

1. v-if、v-else-if、v-else

和 js 的语法一致。

<template>
  <div id="app">
    <div v-if="type === 'first'">First content.</div>
    <div v-else-if="type === 'second'">Second content.</div>
    <div v-else>Third content.</div>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      type: "first",
    };
  },
};
</script>

2. template 上使用 v-if

<template> 上使用 v-if 可以决定是否渲染已被分组的整块内容,与 React 中使用 <Fragment> 类似。

<template>
  <div id="app">
    <template v-if="type === 'first'">
      <div>First content.</div>
      <div>Extra content of first part.</div>
    </template>
    <div v-else-if="type === 'second'">Second content.</div>
    <div v-else>Third content.</div>
  </div>
</template>

元素显示隐藏

与条件渲染不同的是,我们还可以通过样式对元素的显隐进行控制,这也可以降低因 dom 节点的频繁增删对性能的影响。

react 实现

在 React 中我们通过修改内联样式(style)或增删选择器(class)的方式来实现,主要是修改 display 属性。

修改内联样式 style 方式:

<div style={{ display: showName ? "block" : "none" }}>vortesnail</div>

增删类名方式:

import React, { useState } from "react";

function App() {
  const [show, setShow] = useState(true);

  return <div classNames={`app ${show ? "show" : "hide"}`}></div>;
}

export default App;

vue 实现

Vue 中提供了 v-show 指令用于快捷操作元素是否显示,本质上也只是修改内联样式 display 属性。

<div v-show="showName">vortensnail</div>

showNamefalse 时,styledisplaynoneimage.png

showNametrue 时,styledisplay 属性被删除,使用该元素默认值: image.png

列表渲染

React 中使用原生 js 数组语法 map 来渲染列表,而 Vue 中使用指令 v-for 来渲染列表。这一块儿 React 灵活一些,比如可以进行链式调用 lists.filter(...).map(...) 进行过滤。

每个列表项都要添加唯一 key 值,用来减少没必要的 diff 算法对比。

react 实现

渲染数组:

import React, { useState } from "react";

function App() {
  const [lists, setLists] = useState([
    { name: "vortesnail" },
    { name: "sean" },
  ]);

  return (
    <ul id="app">
      {lists.map((item, index) => (
        <li key={item.name + index}>{item.name}</li>
      ))}
    </ul>
  );
}

export default App;

渲染对象:

import React, { useState } from "react";

function App() {
  const [obj, setObj] = useState({
    name: "vortesnail",
    age: 26,
    sex: "male",
    height: 171,
  });

  const renderObj = () => {
    const keys = Object.keys(obj);
    return keys.map((key, index) => <li key={key + index}>{obj[key]}</li>);
  };

  return <ul id="obj-rendering">{renderObj()}</ul>;
}

export default App;

其实就是 js 的语法。

vue 实现

渲染数组:

<template>
  <ul id="app">
    <li v-for="(item, index) in lists" :key="item.name + index">
      {{ item.name }}
    </li>
  </ul>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      lists: [{ name: "vortesnail" }, { name: "sean" }],
    };
  },
};
</script>

渲染对象:

<template>
  <ul id="app">
    <li v-for="(value, key, index) in obj" :key="key + index">
      {{ value }}
    </li>
  </ul>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      obj: {
        name: "vortesnail",
        age: 26,
        sex: "male",
        height: 171,
      },
    };
  },
};
</script>

事件处理

无论是 React 还是 Vue 都对原生 dom 事件做了封装,但在使用上有挺大差异。

react

React 元素的事件处理使用方式和原生 dom 使用比较类似,但是在语法上有一定的不同:

  • 事件的命名采用小驼峰式(比如 onClick ),而不是纯小写。
  • 使用 JSX 语法时你需要传入一个函数作为事件处理函数。
  1. 事件处理函数不传参数

不传参数时,会隐式传递一个事件 event 对象作为处理函数的第一个参数,一般我们会这样写:

import React from "react";

function App() {
  const handleClick = (e) => {
    console.log(e.target);
  };

  return <div onClick={handleClick}>Click me</div>;
}

export default App;
  1. 事件处理函数传递参数

大多数时候我们是需要往处理函数中传递其它参数的,我们可以这也写:

import React from "react";

function App() {
  const handleClick = (e, str) => {
    console.log(e.target);
    console.log(str);
  };

  return <div onClick={(e) => handleClick(e, "vortesnail")}>Click me</div>;
}

export default App;

vue

Vue 中处理事件需要用到一个指令 v-on ,简写为 @ ,接受一个方法名,并以字符串形式传入。

  1. 事件处理函数不传参数
<template>
  <div @click="handleClick">Click me!</div>
</template>

<script>
export default {
  name: "App",
  methods: {
    handleClick(e) {
      console.log(e.target);
    },
  },
};
</script>
  1. 事件处理函数传递参数(可以使用 $event 占位访问事件 event 对象)
<template>
  <div @click="handleClick($event, 'vortesnail')">Click me</div>
</template>

<script>
export default {
  name: "App",
  methods: {
    handleClick(e, str) {
      console.log(e.target);
      console.log(str);
    },
  },
};
</script>

Vue 中还提供了常用的事件修饰符按键修饰符,可以让我们更好地专注处理数据逻辑,而不是处理 DOM 的事件细节。

组件通信

在开发组件时不可避免会遇到父子组件、跨多层级组件之间的通信问题,无论在 React 还是 Vue 中,它们都有对应适合的解决方案。

父子组件通信

有一种很常见的场景:父组件维护了一组数据,并且某个数据的变动也是由父组件定义的函数来执行进行变更的,这个函数可以在父组件及其子组件中去调用,由子组件调用时还可以拿到子组件的数据。

举个例子:点击“改变随机数”按钮产生一个新的随机数,并更新页面值。 22.gif

react 实现

React 中通过 props + 回调函数实现。

父组件 App.jsx

import React, { useState } from "react";
import RandomNum from "./RandomNum";

function App() {
  const [randomNum, setRandomNum] = useState(0);

  const handleUpdateNum = (num) => {
    setRandomNum(num);
  };

  return (
    <div id="app">
      <p>当前随机数为:</p>
      <RandomNum num={randomNum} changeNum={handleUpdateNum} />
    </div>
  );
}

export default App;

子组件 RandomNum.jsx

import React from "react";

function RandomNum(props) {
  const { num, changeNum } = props;

  const handleChangeTitle = () => {
    changeNum(~~(Math.random() * 100));
  };

  return (
    <div>
      <h4>{num}</h4>
      <button onClick={handleChangeTitle}>改变随机数</button>
    </div>
  );
}

export default RandomNum;

一方面是父组件的 randomNum 数据通过 num 传给了子组件,另一方面子组件又通过 changeNum 传递的父组件回调函数接收子组件的数据,从而达到父子组件通信的效果。

Tips:父组件调子组件方法可以通过 forwardRefuseImperativeHandle 实现。

vue 实现

第一种方式与上面 react 实现类似,同样的思路也可以在 vue 中实现,也就是通过 props + 回调函数。

父组件 App.vue

<template>
  <div id="app">
    <p>当前随机数为:</p>
    <random-num :num="randomNum" :changeNum="handleUpdateNum" />
  </div>
</template>

<script>
import RandomNum from "./RandomNum.vue";

export default {
  name: "App",
  components: { RandomNum },
  data() {
    return {
      randomNum: 0,
    };
  },
  methods: {
    handleUpdateNum(num) {
      this.randomNum = num;
    },
  },
};
</script>

子组件 RandomNum.vue

<template>
  <div>
    <h4>{{ num }}</h4>
    <button @click="handleChangeTitle">改变随机数</button>
  </div>
</template>

<script>
export default {
  name: "RandomNum",
  props: ["num", "changeNum"],
  methods: {
    handleChangeTitle() {
      this.changeNum(~~(Math.random() * 100));
    },
  },
};
</script>

第二种方式通过 props + 自定义事件方式。

父组件通过 props 传递数据给子组件,子组件使用 $emit 触发自定义事件,父组件中监听子组件的自定义事件从而获取子组件传递来的数据。其本质也是通过回调函数实现子组件给父组件传数据。

父组件 App.vue

<template>
  <div id="app">
    <p>当前随机数为:</p>
    <random-num :num="randomNum" @changeNum="handleUpdateNum" />
  </div>
</template>

<script>
import RandomNum from "./RandomNum.vue";

export default {
  name: "App",
  components: { RandomNum },
  data() {
    return {
      randomNum: 0,
    };
  },
  methods: {
    handleUpdateNum(num) {
      this.randomNum = num;
    },
  },
};
</script>

子组件 RandomNum.vue

<template>
  <div>
    <h4>{{ num }}</h4>
    <button @click="handleChangeTitle">改变随机数</button>
  </div>
</template>

<script>
export default {
  name: "RandomNum",
  props: ["num"],
  methods: {
    handleChangeTitle() {
      this.$emit("changeNum", ~~(Math.random() * 100));
    },
  },
};
</script>

Tips:父组件调子组件方法可以通过 this.$refs 来实现。

跨多层级组件通信

理论上我们可以通过共同的父组件实现兄弟组件通信,多层 props 传递实现祖孙级组件通信,但是这样会非常麻烦,写到后面代码也别维护了,因为没人维护的来~(我开始学 React 时候做的项目就没有用任何状态管理手段,做到后面我自己都维护不下去了)

所以我们需要更高效且更可具维护性的方案,React 还是 Vue 都提供了这种能力。

react 实现

React 中实现主要借助 React.createContextuseContext 这两个 API 来实现。

根组件 App.jsx

import React, { useState } from "react";
import Title from "./Title";

// 创建Context对象
export const AppContext = React.createContext();

function App() {
  const [randomNum, setRandomNum] = useState(0);

  const handleUpdateNum = (num) => {
    setRandomNum(num);
  };

  return (
    <AppContext.Provider value={{ num: randomNum, changeNum: handleUpdateNum }}>
      <div id="app">
        <Title />
      </div>
    </AppContext.Provider>
  );
}

export default App;

根组件下一层的组件 Title.jsx

import React from "react";
import RandomNum from "./RandomNum";

function Title() {
  return (
    <div>
      <p>当前随机数为:</p>
      <RandomNum />
    </div>
  );
}

export default Title;

孙子组件 RandomNum.jsx

import React, { useContext } from "react";
import { AppContext } from "./App";

function RandomNum() {
  const { num, changeNum } = useContext(AppContext);

  const handleChangeTitle = () => {
    changeNum(~~(Math.random() * 100));
  };

  return (
    <div>
      <h4>{num}</h4>
      <button onClick={handleChangeTitle}>改变随机数</button>
    </div>
  );
}

export default RandomNum;

其实这里的组件通信方式中还可以使用 useReducer 来模拟 react-redux 的使用,不作深究,一般这个在开发复杂组件时会用到。如果是对于我们项目本身,直接用 react-redux、mobx 就完事了。

vue 实现

Vue 中实现跨级组件间通信的方式实在是有点多(除了 vuex 这种工具),主要分析下以下两种怎么用:

  • $attrs$listeners
  • provideinject

第一种方式通过 provideinject

这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在其上下游关系成立的时间里始终生效。如果你熟悉 React,这与 React 的上下文特性很相似。

根组件 App.vue

<template>
  <div id="app">
    <title-component />
  </div>
</template>

<script>
import Title from "./Title.vue";

export default {
  name: "App",
  components: { "title-component": Title },
  provide() {
    return {
      num: this.randomNum,
      changeNum: this.handleUpdateNum,
    };
  },
  data() {
    return {
      randomNum: {
        value: 0,
      },
    };
  },
  methods: {
    handleUpdateNum(num) {
      this.randomNum.value = num;
    },
  },
};
</script>

这里一定要注意,provide 要使用函数返回对象形式,不然拿不到 this.randomNumthis.handleUpdateNum ,这时候 thisundefined

而且在 data 中定义的 randomNum 必须是对象,原因在于将注入的数据变成可响应式的,看官网这段话: image.png

根组件下一层的组件 Title.vue

<template>
  <div>
    <p>当前随机数为:</p>
    <random-num />
  </div>
</template>

<script>
import RandomNum from "./RandomNum";

export default {
  name: "Title",
  components: {
    RandomNum,
  },
};
</script>

孙子组件 RandomNum.vue

<template>
  <div>
    <h4>{{ num.value }}</h4>
    <button @click="handleChangeTitle">改变随机数</button>
  </div>
</template>

<script>
export default {
  name: "RandomNum",
  inject: ["num", "changeNum"],
  methods: {
    handleChangeTitle() {
      this.changeNum(~~(Math.random() * 100));
    },
  },
};
</script>

第二种方式通过 $attrs$listeners 。 其实我觉得这种方式有点类似于 props 的逐层传递,$attrs 可以使子组件通过 props 拿到根组件通过 v-bind 传递的数据,$listeners 可以使子组件通过 this.$emit 触发根组件通过 v-on 绑定的自定义事件回调。

根组件 App.vue

<template>
  <div id="app">
    <title-component :num="randomNum" @changeNum="handleUpdateNum" />
  </div>
</template>

<script>
import Title from "./Title.vue";

export default {
  name: "App",
  components: { "title-component": Title },
  data() {
    return {
      randomNum: 0,
    };
  },
  methods: {
    handleUpdateNum(num) {
      this.randomNum = num;
    },
  },
};
</script>

根组件下一层的组件 Title.vue

<template>
  <div>
    <p>当前随机数为:</p>
    <random-num v-bind="$attrs" v-on="$listeners" />
  </div>
</template>

<script>
import RandomNum from "./RandomNum";

export default {
  name: "Title",
  components: {
    RandomNum,
  },
};
</script>

孙子组件 RandomNum.vue

<template>
  <div>
    <h4>{{ num }}</h4>
    <button @click="handleChangeTitle">改变随机数</button>
  </div>
</template>

<script>
export default {
  name: "RandomNum",
  props: ["num"],
  methods: {
    handleChangeTitle() {
      this.$emit("changeNum", ~~(Math.random() * 100));
    },
  },
};
</script>

Vue 中有一个能实现所有组件间通信的方式,叫做全局事件总线(EventBus),感兴趣的可以瞅瞅:Vue 中全局事件总线(GlobalEventBus)原理及探究过程

缓存优化

在组件内,某个数据值的获取要经过大量复杂的计算,耗时较多时,React 和 Vue 都提供了优化的方法,对于相同输入,必定是同一输出的函数来说,这种结果是可缓存的,不必每次重新渲染时都重新计算一次。

react 的 useMemo 和 useCallback

在 React 中主要提供了两个钩子 useMemouseCallback 。 使用 useMemo 来缓存值,使用 useCallback 来缓存函数。

当子组件使用了 React.memo 时,就可以考虑使用 useMemouseCallback 封装提供给子组件的 props,这样就能够充分利用 memo 带来的浅比较能力,从而减少不必要的重复但无意义的渲染。

假如现在有以下场景:点击“产生随机数”按钮,randomNum 会被更改,组件重新渲染。点击“改变小西瓜数量”按钮,列表长度会递增。

App.jsx

import React, { useState } from "react";
import List from "./List";

function App() {
  const [randomNum, setRandomNum] = useState(0);
  const [listLen, setListLen] = useState(100);

  const handleGenerateRandomNum = () => {
    setRandomNum(~~(Math.random() * 100));
  };

  const handleChangeListLength = () => {
    setListLen((pre) => pre + 1);
  };

  const list = new Array(listLen).fill(1).map((item, index) => {
    return {
      id: index,
      text: `${index}个小西瓜`,
    };
  });

  return (
    <div id="app">
      <List data={list} />
      <span>随机数是:{randomNum}</span>
      <button onClick={handleGenerateRandomNum}>产生随机数</button>
      <button onClick={handleChangeListLength}>改变小西瓜数量</button>
    </div>
  );
}

export default App;

List.jsx

import React, { memo } from "react";

function List(props) {
  const { data = [] } = props;
  console.log(`我是 List 组件,我被渲染了,我的长度是 ${data.length}`);

  return (
    <ul>
      {data.map((item) => (
        <li key={item.id}>{item.text}</li>
      ))}
    </ul>
  );
}

export default memo(List);

如果这时候你打开控制台,疯狂点击“产生随机数”按钮,你会发现这种情况: 33.gif

理论上来说我们是不希望 List 组件在点击“产生随机数”时被重新渲染的,因为 randomNum 这个状态与 List 组件是无关的。可以看到即使我们在子组件中使用了 React.memo 也是没用的,因为每次生成的都是一个在内存中全新的数组。

这时候只需要将依赖 listLen 计算列表的地方使用 useMemo 包裹起来就行了,这样只有在 listLen 变化时,才会去重新计算 list

const list = useMemo(() => {
  return new Array(listLen).fill(1).map((item, index) => {
    return {
      id: index,
      text: `${index}个小西瓜`,
    };
  });
}, [listLen]);

效果如下: 44.gif

useCallback 的使用也是类似,比如我们父组件要向子组件传递方法时,只要依赖没变化,就没必要生成新的方法,也没必要让子组件重新渲染,这时候就可以使用 useCallback 了。

vue 的 computed

Vue 中用 computed 来表示计算属性,计算属性是基于它们的响应式依赖进行缓存的。只在相关响应式依赖发生改变时它们才会重新求值。

App.vue

<template>
  <div id="app">
    <list :data="list" />
    <span>随机数是:{{ randomNum }}</span>
    <button @click="handleGenerateRandomNum">产生随机数</button>
    <button @click="handleChangeListLength">改变小西瓜数量</button>
  </div>
</template>

<script>
import List from "./List.vue";

export default {
  name: "App",
  components: { List },
  data() {
    return {
      randomNum: 0,
      listLen: 100,
    };
  },
  computed: {
    list() {
      return new Array(this.listLen).fill(1).map((item, index) => {
        return {
          id: index,
          text: `${index}个小西瓜`,
        };
      });
    },
  },
  methods: {
    handleGenerateRandomNum() {
      this.randomNum = ~~(Math.random() * 100);
    },
    handleChangeListLength() {
      this.listLen += 1;
    },
  },
};
</script>

List.vue

<template>
  <ul>
    <li v-for="item in data" :key="item.id">{{ item.text }}</li>
  </ul>
</template>

<script>
export default {
  name: "List",
  props: ["data"],
  updated() {
    console.log(`我是 List 组件,我被渲染了,我的长度是 ${this.data.length}`);
  },
};
</script>

另外,如果将 list 的求值放到 methods 中的话,子组件也会重新渲染。

<template>
  <div id="app">
    <list :data="list()" />
  </div>
</template>

<script>
import List from "./List.vue";

export default {
  methods: {
    //...
    list() {
      return new Array(this.listLen).fill(1).map((item, index) => {
        return {
          id: index,
          text: `${index}个小西瓜`,
        };
      });
    },
  },
};
</script>

计算属性不仅有 getter 还有 setter ,具体可查看官方文档

侦听器

watch 的概念其实只有 Vue 才有,它的作用是监听 propsdatacomputed 的变化,执行异步或开销较大的操作。在 React 中通过自定义 hook 也能实现类似 Vue watch 的功能,具体的实现都可以另写一篇了,有机会研究下再做分享。

在 Vue 中用法如下:

watch: { message: { handler(newMsg, oldMsg) { this.msg = newMsg; }, // 代表在
wacth 里声明了 message 这个方法之后立即先去执行 handler 方法 immediate: true, //
深度监听 deep: true, } }

ref

React 和 Vue 都提供了访问原生 DOM 的特性,使用 ref 实现。

react 实现

在当前组件中使用 ref 获得实际 DOM。

import React, { useEffect, useRef } from "react";

function App() {
  const ref = useRef(null);

  useEffect(() => {
    ref.current?.focus();
  }, []);

  return (
    <div id="app">
      <input ref={ref} />
    </div>
  );
}

export default App;

但是有时候我们想要在父组件中获得子组件中的某个实际 DOM 元素就需要用到 React.forwardRef 了。

父组件 App.jsx

import React, { useEffect, useRef } from "react";
import InputComponent from "./InputComponent";

function App() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return (
    <div id="app">
      <InputComponent ref={inputRef} />
    </div>
  );
}

export default App;

子组件 InputComponent.jsx

import React from "react";

const InputComponent = React.forwardRef((props, ref) => {
  return (
    <div id="input-component">
      <input ref={ref} />
    </div>
  );
});

export default InputComponent;

vue 实现

在当前组件中使用 ref 获得实际 DOM。

<template>
  <div id="app">
    <input ref="input" />
  </div>
</template>

<script>
export default {
  name: "App",
  mounted() {
    this.$refs.input.focus();
  },
};
</script>

在 vue 中要获取子组件的实际 DOM 需要先获取子组件实例,再通过子组件实例的 $refs 拿到 DOM。

父组件 App.vue

<template>
  <div id="app">
    <input-component ref="inputComponent" />
  </div>
</template>

<script>
import InputComponent from "./InputComponent.vue";

export default {
  name: "App",
  components: {
    InputComponent,
  },
  mounted() {
    this.$refs.inputComponent.$refs.inputRef.focus();
  },
};
</script>

子组件 InputComponent.vue

<template>
  <div id="input-component">
    <input ref="inputRef" />
  </div>
</template>

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

受控与 v-model

React 中 inputtextarea 等非受控组件通过 onChange 事件获取当前输入内容,将当前输入内容作为 value 传入,此时它们就成为受控组件,这样做的目的是可以通过 onChange 事件控制用户输入,比如使用正则表达式过滤不合理输入。

Vue 中使用 v-model 实现数据双向绑定。

react 实现

import React, { useState } from "react";

function App() {
  const [name, setName] = useState("vortesnail");

  const handleChange = (e) => {
    setName(e.target.value);
  };

  return (
    <div id="app">
      <input value={name} onChange={handleChange}></input>
    </div>
  );
}

export default App;

vue 实现

v-model 用于表单数据的双向绑定,其实它就是一个语法糖,这个背后就做了两个操作:

  • v-bind 绑定一个 value 属性。
  • v-on 指令给当前元素绑定 input 事件。
<template>
  <div id="app">
    <input v-model="name" />
    // 本质上相当于
    <!-- <input :value="name" @input="(e) => (name = e.target.value)" /> -->
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      name: "vortensial",
    };
  },
};
</script>

对于自定义组件也可以使用 v-model ,子组件应该有如下操作:

  • 接收一个 value 作为 prop
  • 触发 input 事件,并传入新值。

父组件 App.vue

<template>
  <div id="app">
    <input-component v-model="name" />
  </div>
</template>

<script>
import InputComponent from "./InputComponent.vue";

export default {
  name: "App",
  components: { InputComponent },
  data() {
    return {
      name: "vortensial",
    };
  },
};
</script>

子组件 InputComponent.vue

<template>
  <div id="input-component">
    <input :value="value" @input="handleInput" />
  </div>
</template>

<script>
export default {
  name: "InputComponent",
  props: ["value"],
  methods: {
    handleInput(e) {
      this.$emit("input", e.target.value);
    },
  },
};
</script>

插槽

这部分得先从 Vue 中的插槽讲起,个人觉得 Vue 中默认插槽、具名插槽、作用域插槽的这种划分已经覆盖了至少我所接触到的所有场景了,而 React 并没有这种划分,万物皆 props

vue

vue 中通过 <slot> 实现插槽功能,包含默认插槽、具名插槽、作用域插槽。

默认插槽

默认插槽使用 <slot></slot> 在组件中占了一个预留位置,使用该组件的起始标签和结束标签内包含的所有内容都会被渲染到这个占位的地方。

父组件 App.vue

<template>
  <div id="app">
    <title-component>
      <div>我是内容</div>
    </title-component>
  </div>
</template>

<script>
import Title from "./Title.vue";

export default {
  name: "App",
  components: { "title-component": Title },
};
</script>

<style></style>

子组件 Title.vue

<template>
  <div>
    <h1>我是标题</h1>
    <slot></slot>
  </div>
</template>

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

渲染出来的真实 DOM 结构验证了我们所说的“占位”: image.png

具名插槽

默认插槽只能插入一个插槽,当插入多个插槽时需要使用具名插槽。使用 <slot name="xxx"> 形式来定义具名插槽。

默认插槽的 namedefaultv-slot 可简写为 #

子组件 Page.vue

<template>
  <div>
    <header>
      <slot name="header"> Header content. </slot>
    </header>
    <main>
      <slot> Main content. </slot>
    </main>
    <footer>
      <slot name="footer"> Footer content. </slot>
    </footer>
  </div>
</template>

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

父组件 App.vue

<template>
  <div id="app">
    <page>
      <template #header>
        <div>This is header content.</div>
      </template>
      <template>
        <div>This is main content.</div>
      </template>
      <template #footer>
        <div>This is footer content.</div>
      </template>
    </page>
  </div>
</template>

<script>
import Page from "./Page.vue";

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

作用域插槽

有时让插槽内容能够访问子组件中才有的数据是很有用的。

子组件 Page.vue

<template>
  <div>
    <header>
      <slot name="header"> Header content. </slot>
    </header>
    <main>
      <slot :main="main"> Main content. </slot>
    </main>
  </div>
</template>

<script>
export default {
  name: "Page",
  data() {
    return {
      main: {
        title: "我是文章标题",
        content: "我是文章内容",
      },
    };
  },
};
</script>

父组件 App.vue

<template>
  <div id="app">
    <page>
      <template v-slot:header>
        <div>This is header content.</div>
      </template>
      <template v-slot:default="{ main }">
        <div>{{ main.title }}</div>
        <div>{{ main.content }}</div>
      </template>
    </page>
  </div>
</template>

<script>
import Page from "./Page.vue";

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

注意,这里 v-slot:default 没有简写。只有明确定义了 name 的才能使用,比如 #header="xxx"

react

React 中可以通过 props.childrenRender Props 实现 Vue 中的插槽功能。

props.children

其实 props.children 就是子组件起始和结束标签包裹的任何元素,和默认插槽没区别。

父组件 App.jsx

import React from "react";
import Title from "./Title";

function App() {
  return (
    <div id="app">
      <Title>
        <div>我是内容</div>
      </Title>
    </div>
  );
}

export default App;

子组件 Title.jsx

import React from "react";

function Title(props) {
  return (
    <div>
      <h1>我是标题</h1>
      {props.children}
    </div>
  );
}

export default Title;

render props

记住在 React 中万物皆 props 就行了,我们来模拟实现作用域插槽。

子组件 Page.jsx

import React, { useState } from "react";

function Page(props) {
  const [main, setMain] = useState({
    title: "我是文章标题",
    content: "我是文章内容",
  });

  return (
    <div>
      <header>{props.header || "Header content."}</header>
      <main>{props.renderMain ? props.renderMain(main) : "Main content."}</main>
    </div>
  );
}

export default Page;

父组件 App.jsx

import React from "react";
import Page from "./Page";

function App() {
  return (
    <div id="app">
      <Page
        header={<div>This is header content.</div>}
        renderMain={(main) => (
          <>
            <div>{main.title}</div>
            <div>{main.content}</div>
          </>
        )}
      />
    </div>
  );
}

export default App;

我个人在 React 开发中迄今为止没用到类似作用域插槽的这种功能,不知道为什么在 Vue 中那么推崇。😂

逻辑复用

无论是什么框架,逻辑代码的复用这件事都是必须考虑的,在 React 中可以使用自定义 hook 抽离出经常使用到的逻辑达到复用效果,在 Vue2 中可以使用 mixins 来实现。

react

在 React 16.8 以前,我们复用代码逻辑的常用方式是 HOC,现在我们常用自定义 Hook 来实现代码复用。 ​ 假设现在有以下场景,A 组件和 B 组件中都要在初次渲染时请求数据,但是又不想分别在两个组件中都去写请求的代码逻辑。

HOC

首先我们定义一个高阶函数 withData ,它接收组件并返回一个接收 props 的匿名函数,在该函数内写我们要复用的代码逻辑,然后将 props 和复用代码的“结果”同样作为 prop 传给被高阶函数包裹的组件,这样在该组件中就能通过 props 拿到复用代码的“结果”了。

import React, { useState, useEffect } from "react";

const withData = (Component) => (props) => {
  const [results, setResults] = useState([]);

  const fetchData = async () => {
    const response = await fetch("https://pokeapi.co/api/v2/pokemon");
    const data = await response.json();
    setResults(data.results);
  };

  useEffect(() => {
    fetchData();
  }, []);

  return <Component {...props} results={results} />;
};

const A = (props) => {
  const { title, results } = props;
  return (
    <div>
      <h1>{title}</h1>
      <ul>
        {results.map((pokemon) => (
          <li key={pokemon.name}>{pokemon.name}</li>
        ))}
      </ul>
    </div>
  );
};

const B = (props) => {
  return <div>List length: {props.results.length}</div>;
};

const WrappedA = withData(A);
const WrappedB = withData(B);

function App() {
  return (
    <div id="app">
      <WrappedA title="列表数据如下:" />
      <WrappedB />
    </div>
  );
}

export default App;

HOC 容易造成深层次的嵌套、可读性差、调试困难,并且重名 props 可能会被覆盖。

自定义 hook

自定义 hook 看起来就简单很多了,本质上就是把原本要在组件中写的代码包装到另一个 hook 中,要用的时候在组件里面调一下就行了。

import React, { useState, useEffect } from "react";

const useData = () => {
  const [results, setResults] = useState([]);

  const fetchData = async () => {
    const response = await fetch("https://pokeapi.co/api/v2/pokemon");
    const data = await response.json();
    setResults(data.results);
  };

  useEffect(() => {
    fetchData();
  }, []);

  return results;
};

const A = (props) => {
  const results = useData();
  const { title } = props;

  return (
    <div>
      <h1>{title}</h1>
      <ul>
        {results.map((pokemon) => (
          <li key={pokemon.name}>{pokemon.name}</li>
        ))}
      </ul>
    </div>
  );
};

const B = (props) => {
  const results = useData();

  return <div>List length: {results.length}</div>;
};

function App() {
  return (
    <div id="app">
      <A title="列表数据如下:" />
      <B />
    </div>
  );
}

export default App;

vue

mixins

一个混入对象可以包含任意组件选项。当组件使用混入对象时,所有混入对象的选项将被“混合”进入该组件本身的选项。

根组件 App.vue

<template>
  <div id="app">
    <a-component title="列表数据如下:" />
    <b-component />
  </div>
</template>

<script>
import A from "./A.vue";
import B from "./B.vue";

export default {
  name: "App",
  components: { "a-component": A, "b-component": B },
};
</script>

子组件 A.vue

<template>
  <div>
    <h1>{{ title }}</h1>
    <ul>
      <li v-for="pokemon in results" :key="pokemon.name">
        {{ pokemon.name }}
      </li>
    </ul>
  </div>
</template>

<script>
import { dataMixin } from "./mixins";

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

子组件 B.vue

<template>
  <div>List length: {{ results.length }}</div>
</template>

<script>
import { dataMixin } from "./mixins";

export default {
  name: "A",
  mixins: [dataMixin],
};
</script>

混入 mixins.js

export const dataMixin = {
  data() {
    return {
      results: [],
    };
  },
  created() {
    this.fetchData();
  },
  methods: {
    async fetchData() {
      const response = await fetch("https://pokeapi.co/api/v2/pokemon");
      const data = await response.json();
      this.results = data.results;
    },
  },
};

通过观察我们会发现这和 React 中自定义 hook 很像,就是把原本要写在组件本身里的逻辑换了个地方写而已。 ​

  • mixins 容易冲突:因为每个特性的属性都被合并到同一个组件中,组件内同名的属性或方法会把 mixins 里的覆盖掉。
  • 可重用性有限:我们不能向 mixins 传递任何参数来改变它的逻辑,这降低了它们在抽象逻辑方面的灵活性。
  • 数据来源不清晰:组件里所使用的 mixins 里的数据或方法在当前组件代码里搜索不到,易造成错误的解读,比如被当成错误代码或冗余代码而误删。

结语

通篇读下来会发现内容还是比较简单的,本文目的就像开头说的,通过对比的方式让大家能在两个框架之间建立起一个认知桥梁,这样子切换技术栈时会更容易接受点。我个人也认为这样的学习方式对于工作来说会更有效率些,首先让自己能干活,再去思考深入的问题。

当然啦,文中讲的点并不是 React 或 Vue 的全部,还需要你我在学习工作中继续探索~

这是我的 github/blog,若有帮助,赏个 star 🌟

参考

Vue2 官方文档
React 官方文档
为什么我们放弃了 Vue?Vue 和 React 深度对比