开发中遇到关于Mobx的一些问题

2,755 阅读19分钟

概念和问题

下文介绍中使用的React版本为: 17.0.2, Mobx版本为: 4.15.4, Mobx-React版本为: 6.3.1.

下文所有代码示例, 都可以从codesandbox去直接模拟.

目前在项目中有使用数据流管理框架Mobx, 但感觉使用的时候, 很多使用细节都不够注意, 或者一些关键内容比如action, observer等, 都只是知道要用, 但是不知道为什么要用, 不知道什么情况下需要使用才是最优选择. 这篇文章, 就尝试从自己在React项目中使用Mobx遇到的一些问题进行归纳总结, 对于一些使用细节尝试做一些原理性的解答.

首先抛出几个问题:

  1. 什么情况下需要使用@observer?
  2. 怎么判断组件中是否使用了observable state呢?
  3. 为什么要使用@action?
  4. 异步Action要怎么处理, 需要使用runInAction吗?
  5. 为什么在React项目中, 即使没有使用@action, 多次改变Observable的值也不会造成多次re-render呢?

在阅读本文章之前, 可以先行阅读一下Mobx官方文档, 多读几遍官方文档一定会对自己更好的理解MObx产生一定帮助.

首先, 需要先了解几个概念:

  • Observable State: 所有设置为Observable的可以改变的值.
  • Derivations: 通过Observable State直接计算得到的值, 比如@computed的值.
  • Reactions: 与Derivations类似, 也是基于Observable State, 但并不是为了计算某个值, 而是产生一个动作(比如发起一个请求, 改变节点值等).
  • Ation: 所有修改Observable State的动作.

PS: 注意这里有所区分, 所有改变state的操作都是Action, 而Mobx中的同名的api在下文中会用@action来表示

四个概念之间的关系可以参照下面这张图:

Mobx概念关系图

上面的图很好的解释了四者之间的关系:

Action修改State, State的改变会更新DerivationsReactions, 同时Derivations的改变也会触发Reactions.

其实比较特殊的是Derivations, 可以看到它具有双重的身份, 在State -> Derivations的关系中, 它是一个观察者observer, 在State发生变化时, Derivations会发生变化; 而在Derivation -> Reactions的关系中, 它又是一个被观察者observable, 当Derivations发生变化后, 会触发Reactions.

Mobx中, computed的值就承担这Derivations的角色.

动态的依赖更新

先来看一个例子:

class Store {
  @observable firstName = 100;
  @observable lastName = 200;
  @observable nickName: undefined | number = undefined;

  @computed
  get fullName() {
    const fullName = this.firstName + this.lastName;
    console.log("computed fullName: ", fullName);
    return fullName;
  }
  
  @action
  changeFirst = () => {
    this.firstName = Math.random();
  };
  
  @action
  changeNick = () => {
    this.nickName = Math.random();
  };
}

const store = new Store()

const Update = observer(() => {
  console.log("Update render");

  return (
  	<>
    	{store.nickName ? (
        <div>nickName: {store.nickName}</div>
      ) : (
        <div>fullName: {store.fullName}</div>
      )}
			<button onClick={store.changeNick}>改变nickName</button>
			<button onClick={store.changeFirst}>改变firstName</button>
    </>
  )
});

初始状态下,nickNameundefined, Update直接依赖nickNamefullName, 间接依赖项是firstNamelastName, 依赖关系可以参考下图:

但是当我们点击改变nickName按钮后, nickName发生了变化, 思考一个问题, 当nickName有值后, Update组件还会依赖fullName吗? 这时如果firstNamelastName发生变化, Update组件会render吗?

答案都是.

此时的依赖关系发生了变化.

这就是Mobx的动态依赖更新, 它的依赖关系是在框架运行时计算得到的, 这样的好处在于, 可以保证observer只依赖于它所需要的依赖.

懒加载机制

还是上面的那个例子, 当nickNametruth时, Update组件已经不再依赖fullName, 那么假如这时候, 我们改变firstName或者lastName的值, 我们已知这时候Update组件已经不再依赖fullName, 所以不会因为firstNamelastName的变化重新render, 那这时候fullName的值会因为firstNamelastName的变化重新计算吗? 控制台会按照我们的预期打印出值吗?

答案也是****.

这就是Mobx的性能优化的手段之一: 懒加载机制.

要想了解懒加载的机制, 就要首先了解当一个Action发生时, Mobx内部发生了什么变化, 关于这部分的具体改变流程, 可以参考Mobx设计思想与实现从懒加载到MobX响应机制部分, 有详细的流程图.

这篇文章里提到了, 把Mobx内部的改变过程大致分为了两个阶段:

  • 冒泡阶段: 即被修改的observable去通知它的observer修改状态, 这个过程是级联的(或者说是递归发生的). 在冒泡阶段除了修改状态, 实际上并没有发生其他事情
  • 执行阶段: 当冒泡阶段结束, 所有的状态都已经被修改完成后, 开始执行需要的操作

冒泡阶段, Mobx设计思想与实现中有详细的流程图, 这里不再重复, 执行阶段的执行顺序, 这篇文章也有所介绍, 这里引用简单的概括一下, 以初始状态, nickName没值的时候举例, 当改变firstName时:

在冒泡阶段, Mobx会把经过的Reaction都放进一个待执行的队列里, 执行阶段就从队列中取出去执行, 但是这里的Reaction不包括computed value, 所以上面例子中的fullName是不会加入队列的.

在执行阶段, Mobx拿到observerUpdate组件后, 会检查它的依赖, 发现fullName还处于possible change的状态, 就会先确定fullName的值, 然后再确定自己所依赖的值.

那么就涉及到了上面提到的懒加载和依赖动态更新, 因为computed value不会在冒泡阶段加入到待执行队列中, 而是通过Reaction检查依赖的时候来确认它的值的, 所以, 如果computed value没有任何observer依赖, 在执行阶段, 就不会有任何Reaction执行到它, 它自然也就不会执行了.

所以当nickNametruth时, fullName不再被Reactions依赖的时候, 即使改变了firstName或者lastName的值, fullName也不会重新计算.

那么我们新增一个方法, 交换firstNamelastName, 交换后计算得出的fullName值不变:

@action
exchangeFirstAndLastInAction = () => {
  const temp = this.firstName;
  this.firstName = this.lastName;
  this.lastName = temp;
};

假设这时候, nickNamefalsy, 也就是Update还是会直接依赖fullName的, 这时候, 触发exchangeFirstAndLastInAction方法, Update会重新render吗?

答案是**不会**, 但这里的fullName还是会重新计算一次.

那么假如把这里的@action去掉, 是否会发生改变呢?

这里可以自己思考一下, 下文会对这个问题做出解答.

Action

上面有提到, Mobx内部更新的过程分为冒泡阶段和执行阶段, 那为什么Mobx要分成两个阶段而不是修改状态立即执行呢?

这其实也是Mobx性能优化的一个设计, 上面的例子比较简单, 当触发changeFirst方法时, 只对一个state值做了修改, 但是当一个方法中有多个Action发生时, 如果每次都是立即执行, 那么可能每一个Action 都会改变同一个computed value, 我们其实需要的只是一个最终值, 但立即执行却产生了很多无用的中间态.

而等到所有的状态更新和Action都结束之后再去求值, 就只需要最终执行一次求出结果即可, 这其实就是一个批处理.

这时候就需要引入Mobx中的另外一个概念: transition.

transition是一个批处理的底层API, 是用来做批量更新的, 在transition的事务结束前, 不会通知任何的观察者.

transition被引入的目的就是为了标注出一次完整的更新冒泡阶段的开始和结束.

官方文档上有一个例子, 看一下就会明白它的作用:

import {observable, transaction, autorun} from "mobx";

const numbers = observable([]);

autorun(() => console.log(numbers.length, "numbers!"));
// 输出: '0 numbers!'

transaction(() => {
    transaction(() => {
        numbers.push(1);
        numbers.push(2);
    });
    numbers.push(3);
});
// 输出: '3 numbers!'

在平时开发中, 我们基本不会用到transition,也不推荐使用, 但我们比较熟悉的@action, 就是transition的上层封装, 那么我们来把上面的例子做个修改补充, 看下具体的情况:

class Store {
  @observable firstName = 100;
  @observable lastName = 200;

  @computed
  get fullName() {
    const fullName = this.firstName + this.lastName;
    console.log("computed fullName: ", fullName);
    return fullName;
  }

  changeFirstAndLastWithoutAction = () => {
    this.firstName = Math.random();
    this.lastName = Math.random();
  };
  
  @action
  changeFirstAndLastWithAction = () => {
    this.firstName = Math.random();
    this.lastName = Math.random();
  };
}

const store = new Store();

// 当依赖的observable state值发生改变时, autorun会再次触发
autorun(() => console.log("autorun中打印fullName: ", store.fullName));

const Update = observer(() => {
  console.log("Update render");
    return (
      <div>
        fullName: {store.fullName}
        <button onClick={store.changeFirstAndLastWithoutAction}>
          不在action中改变firstName和lastName
        </button>
        <button onClick={store.changeFirstAndLastWithAction}>
          action中改变firstName和lastName
        </button>
      </div>
    );
  }
});

当点击changeFirstAndLastWithoutAction方法时, 我们同步改变了firstNamelastName的值, 但这个方法没有使用action, 这个时候, 控制台会打印什么内容呢?

然后点击changeFirstAndLastWithAction方法, 同步改变了firstNamelastName的值, 这个方法使用了action, 这个时候控制台会打印什么内容呢? 与没有使用action的方法有区别吗?

这里建议先自己思考一下, 在看下面的结果.

首先是, 点击changeFirstNameAndLastNameWithoutAction方法时, 没有action的情况下, 看下输出:

// computed fullName:  0.9666486941396348
// autorun fullName 0.9666486941396348
// computed fullName:  0.9796278940563887
// autorun fullName 0.9796278940563887
// Update render 

然后是点击changeFirstNameAndLastNameWithAction方法, 这个方法使用了action, 看下输出:

// computed fullName:  0.46508722412398207
// autorun fullName 0.46508722412398207
// Update render 

根据输出的结果, 可以看出, 虽然都是同步改变了firstNamelastName的值, 但是打印的内容却有差异, computedautorun里的内容在没有使用action的情况下都执行了两遍, 而在存在action的情况下, 却只打印了一遍.

这也是Mobx的最佳实践里, 建议对observable state的修改都加上@action的原因, 加上了action, 就可以理解为是做了批处理, 所以并不会每修改一个state就立即去执行改变后的值, 而是先经历冒泡阶段, 改变state/Derivation/Reaction的状态, 然后等到action中的内容运行结束之后, 才会去执行改变后的值, 这样才算是一次完整的更新.

官方文档上介绍action的时候, 有一句话也代表着相同含义:

action期间, 生成的中间值或未完成的值对应用的其余部分是不可见的.

而没有加action的情况, 就可以理解为修改了某个observable state之后立即执行.

但在这里, 细心的同学可能发现了, 为什么autorun中的内容执行了两边, computed fullName也打印了两个不同的结果, 而ReactUpdate组件却只打印了一遍render呢?

这个问题暂时打个问号, 大家也不妨自己结合React思考一下是为什么, 这边文章的后半部分会对这个问题做出解答.

现在, 就可以回答上面的那个问题了, 当交换firstNamelastName的方法, 去掉了@action, 输出的结果应该是:

exchangeFirstAndLastInAction = () => {
  const temp = this.firstName;
  this.firstName = this.lastName;
  this.lastName = temp;
};

// computed fullName:  0.2032942583568902
// autorun fullName 0.2032942583568902
// computed fullName:  0.46508722412398207
// autorun fullName 0.46508722412398207
// Update render 

可见, 虽然最终fullName的值是不变的, 但没有@action的批处理, 还是会引起两次autorun的执行和一次Update的重新render.

异步改变state的值

前面有提到action 的作用, 就是类似于批处理的一个行为, 通过这样的处理, Mobx能判断何时所有的state变更都已经结束, 也正是因为这点, Mobx的最佳实践里推荐大家都使用action.

但是对于异步的处理, action就显得力不从心了, 比如下面的例子:

class Store {
  @observable firstName = "jack";
  @observable lastName = "ma";
  @observable age = (Math.random() * 100).toFixed(2);
  @observable money = (Math.random() * 1000000).toFixed(2);

  @computed
  get fullText() {
    return this.firstName + " " + this.lastName + "的年纪是" + this.age + ", 拥有财富" + this.money;
  }

  @action
  asyncChangeNameWithoutRunInAction = () => {
    this.firstName = Math.random().toFixed(3);
    this.lastName = Math.random().toFixed(3);
    setTimeout(() => {
      this.age = (Math.random() * 100).toFixed(2);
      this.money = (Math.random() * 1000000).toFixed(2);
    }, 0);
  };
}

const store = new Store();

autorun(() => console.log("autorun: ", store.fullText));

const AsyncUpdate = observer(() => {
  console.log("render");

  return (
    <div>
      <div>{store.fullText}</div>
      <div>
        <button onClick={store.asyncChangeNameWithoutRunInAction}>
          异步改变所有值(无runInAction)
        </button>
      </div>
    </div>
  );
});

还是通过打印的内容来看运行机制, 当我们点击了异步改变所有值(无runInAction)按钮时, 控制台会打印什么呢?

可以先自己思考一下, 这时候autorun会运行几次, AsyncUpdaterender几次? 为什么?

打印结果如下:

// autorun:  0.544 0.477的年纪是3.80, 拥有财富185706.89 
// render 
// autorun:  0.544 0.477的年纪是65.80, 拥有财富185706.89 
// render 
// autorun:  0.544 0.477的年纪是65.80, 拥有财富876883.29 
// render 

异步之前改变的state, 被action正确的处理了, 两次改变, 只引起了一次autorunrender, 而异步的两次改变, 却引起了两次autorunrender.

虽然从表面上看, setTimeout内的改变还是在action的覆盖范围之内, 但其实了解浏览器的事件循环机制我们就知道, 异步的回调函数在运行时和同步的asyncChangeNameWithoutRunInAction方法已经不在一个函数栈里了, 这也就意味着超出了action的处理范围.

这时候有多种处理方式, 这里以runInAction举例:

@action
asyncChangeNameInAction = () => {
  this.firstName = Math.random().toFixed(3);
  this.lastName = Math.random().toFixed(3);
  setTimeout(() => {
    runInAction(() => {
      this.age = (Math.random() * 100).toFixed(2);
      this.money = (Math.random() * 1000000).toFixed(2);
    });
  }, 0);
};

这时候触发asyncChangeNameInAction方法, 控制台打印的内容是:

// autorun:  0.347 0.578的年纪是65.80, 拥有财富876883.29 
// render 
// autorun:  0.347 0.578的年纪是38.58, 拥有财富829080.36 
// render 

异步前后, 虽然都改变了两次state, 但是分别都只引起了一次autorunrender.

什么情况下需要使用@observer

首先说明一下, 下文提到的@observerobserver都是指mobx-react中的observerAPI.

引用官方文档-最佳实践的一句话:

observer only enhances the component you are decorating, not the components called by it. So usually all your components should be wrapped by observer. Don't worry, this is not inefficient. On the contrary, more observer components make rendering more efficient as updates become more fine-grained.

翻译一下就是:

@observer只会增强你正在装饰的组件, 而不会增强该组件内部调用的组件. 所以, 通常情况下, 你的组件都应该使用@observer装饰. 不用担心, 这不是低效的, 相反, observer组件越多, 渲染效率越高.

所以, 应该在所有使用了@observable state的组件上使用@observer, 无论外层是否已经使用.

@observer允许组件独立于其父组件进行渲染, 所以这也意味着, 你使用的@observer越多, 你的性能就越好, 而@observer本身的开销是可以忽略不计的; 而且对于使用了observable state但却没有使用@observer的组件, 还可能会产生不及预期的效果, 导致bug.

看下面的代码:

class Store {
  @observable first = 100;
  @observable last = 200;
  @observable numArr = [1, 2, 3];

  @computed
  get total() {
    return this.first + this.last;
  }

  @action
  changeFirst = () => {
    this.first = Math.random();
  };

  @action
  changeLast = () => {
    this.last = Math.random();
  };
  
  @action
  addNumToArr = () => {
    this.numArr.push(Math.random());
  };

  @action
  removeNumOfArr = () => {
    this.numArr.pop();
  };
}

const store = new Store();

interface ChildProps {
  arr: number[];
}

@observer
class Child1 extends React.Component<ChildProps> {
  render() {
    console.log("child1 render");
    return (
      <div>
        <div>child1 - with - observer {store.last}</div>
        <div>
          {this.props.arr.map((item, index) => (
            <span key={index}>{item} - </span>
          ))}
        </div>
      </div>
    );
  }
}

class Child2 extends React.Component<ChildProps> {
  render() {
    console.log("child2 render");
    return (
      <div>
        <div>child1 - without - observer {store.last}</div>
        <div>
          {this.props.arr.map((item, index) => (
            <span key={index}>{item} - </span>
          ))}
        </div>
      </div>
    );
  }
}

// class Child3 extends React.Component {
//   render() {
//     return (
//       <div>
//         <div>child1 - without - observer {store.last}</div>
//         <div>
//           {store.numArr.map((item, index) => (
//             <span key={index}>{item} - </span>
//           ))}
//         </div>
//       </div>
//     );
//   }
// }

const Parent = observer(() => {
  console.log("parent render");
  const arr = store.numArr;

  return (
    <div>
      first: {store.first}
      <Child1 arr={arr} />
      <Child2 arr={arr} />
      // <Child3 />
      <div>
        <button onClick={store.changeFirst}>改变first</button>
        <button onClick={store.changeLast}>改变last</button>
        <button onClick={store.addNumToArr}>数组增加内容</button>
        <button onClick={store.removeNumOfArr}>删除数组最后一项</button>
      </div>
    </div>
  );
});

Child1Parent@observer的, Child2没有使用@observer.

可以思考下, 当点击改变first按钮之后, 应该输出什么内容? 点击改变last按钮之后, 应该输出什么内容?

当点击改变first按钮, 输出内容为:

// parent render 
// child2 render 

可以发现, 改变了first, 导致父组件重新渲染, 而父组件重新渲染的时候, 并没有引起Child1的重新渲染.

这是为什么呢? 可以自己独立思考一下, 下文会做解释说明.

当点击改变last按钮之后, 输出的内容为:

// child1 render 

只有Child1组件重新渲染了, 因为Parent组件不依赖于改变了的store.last, 所以这次渲染是独立于Parent组件的, 这就是上面说的"@observer允许组件独立于其父组件进行渲染"的具体体现.

不使用@observer造成的问题

还是上面的例子, 如果我们点击了数组增加内容删除数组最后一项按钮 会发现, 只有Child1中动态渲染除了最新的内容, 而Child2Child3却没有实时的渲染出我们修改后的数组.

这里注意看, Child2Child3使用observalbe数据的方式是不同的, Child2是通过父组件传递的observable数据, 而Child3是直接从store中读取的observable数据, 但这两者都代表着在组件中使用了observable数据.

所以, 两者在没有使用@observer的情况下的表现是一致的, 都不会因为store.numArr的改变而改变.

这里借用官方文档的描述就是:

So if observable objects / arrays / maps are passed to child components, those have to be wrapped with observer as well. This is also true for any callback based components.

那怎么解决这个问题呢?

最简单的方法, 就是Child2Child3两个组件都改成@observer组件.

针对Child2组件, 还可以把arr数组转换成plain data进行传递.

<Child2 arr={toJS(arr)} />

If you want to pass observables to a component that isn't an observer, either because it is a third-party component, or because you want to keep that component MobX agnostic, you will have to convert the observables to plain JavaScript values or structures before passing them on.

切记, 如果你实在不想把组件标记为@observer, 那么请确定你只指传递了plain data.

怎么判断observable state是否在组件内使用了呢?

在上面的例子中, 不知道大家是否发现一个问题, 当我点击了数组增加内容删除数组最后一项按钮, 增删了store.numArr这个observable的值的时候, 为什么Parent组件中明明使用到了store.numArr的值, 但是却没有引起Parent的重新渲染呢?

这样看来, 从Mobx的角度来说, Parent组件内并没有使用store.numArr数据, 那应该怎么判断observable state是否在组件内使用了呢?

官方文档中有这样两段话, 个人感觉可以很好的解释这个问题:

observer works best if you pass object references around as long as possible, and only read their properties inside the observer based components that are going to render them into the DOM / low-level components. In other words, observer reacts to the fact that you 'dereference' a value from an object.

Components wrapped with observer only subscribe to observables used during their own rendering of the component.

下面一段话的意思就是使用了@observer的组件只会订阅在组件rendering期间使用到的observable数据, 对于React组件来说, 就是函数组件的重新渲染和class componentrender()方法.

那怎么判断是否使用了呢?

加粗的那句话是重点, 当你从一个observable数据中, 解引用, 使用某个具体的值的时候, 就认为是在该组件中使用到了observable数据.

现在让我们尝试把Parent组件做一个修改:

const Parent = observer(() => {
  console.log("parent render");
  const arr = store.numArr;
  console.log(arr[0])
	// 下面内容都不变
});

这样, 当我们改变了store.numArr时, Parent组件就会重新渲染了.

也正因为如此, 在传递observable数据的时候, 解引用的时机, 越晚越好, 这点是与react-redux不同的.

@observer是怎么做优化的

observer高阶组件/装饰器的作用就是将React组件转化成响应式组件, 它是由mobx-react包提供的, 但这里需要注意, mobx-react只有v6及以上版本才支持基于hooks的组件.

Function Component

当对Function Component使用observer时, 会自动给对应的组件应用React.memo方法.

Class Component

当对Class Component使用observer时, this.statethis.props都会变成observable的, 所以组件会对render方法中使用到的propsstate的所有改变都做出响应.

并且, 当对Class Component使用observer时, 是不支持shouComponentUpdate方法的, 如果使用了, 控制台会有警告. 所以, 这里推荐使用React.PureComponent, 如果使用的不是React.PureComponent而是React.Component , 那么Mobx-React会按照React.PureComponent的内容实现自动给组件打上补丁.

到这里就可以清楚了, 为什么上面点击改变first按钮之后Parent组件重新渲染了, 而被observer包裹的Child1组件没有重新渲染呢, 就是因为observerClass Component组件做的类似于React.PureComponent的处理.

Callback Component的处理

这里需要注意, 对于callback component中使用到的observable数据需要使用<Observer>组件包裹, 才能是响应式的.

const TodoView = observer(({ todo }: { todo: Todo }) => {
	// WRONG: 因为onRender不是observer的, 所以当todo.title发生改变时, 并不能在GridRow组件中有所体现
    return <GridRow onRender={() => <td>{todo.title}</td>} />

    // 正确的做法: 使用<Observer>组件包裹callback component
    return <GridRow onRender={() => <Observer>{() => <td>{todo.title}</td>}</Observer>} />
})

为什么在React项目中, 即使没有使用@action, 多次改变Observable的值也不会造成多次re-render呢?

文章开头提到的5个问题, 前四个都已经在前文解答了, 现在我们来解答最后一个问题.

这个问题也可以换一种方式问, 为什么autorun中的内容都多次运行, 但是React组件却没有多次re-render呢?

legacy mode 为例, 我们都知道, 当我们在组件的生命周期或者事件回调中使用this.setStates或者useState触发状态更新时, 不能够实时的拿到更新后的state的值, 而背后的原因就是React将同一上下文中触发的更新合并为了一个更新, 也就是批处理.

@action也可以理解为是类似于ReactDOM.unstable_batchedUpdates的批处理机制, 所以上面的例子, 当在React的事件回调中触发了一系列同步的observalble值的修改时, 即使没有使用@action, 也会做优化处理.

但其实这个问题本身就存在问题, 并不是所有没有使用@action的多次改变observable值的情况都不会造成组件的多次re-render, 这种情况仅存在于React替我们做了批处理的情况下. 如果在异步代码中或者原生事件处理中(window.addEventListener)做了上述操作, React的表现与autorun的表现是一致的.

PS: blocking modeconcurrent mode的批处理方式与legacy mode不同, 具体区别可以参考Feature Comparison.

更详细的说明可以参考这条issue, 但是这条issue中有一个问题, 看的时候需要注意:

Simply put async functions are unbatchable by both mobx/react (can change in the future).

这点在文章使用的版本里面已经支持了.

Tips

  1. 上文所有代码示例, 都可以从codesandbox去直接模拟.
  2. 使用trace()方法可以帮助你很好的进行调试.
  3. 多看几遍Understanding reactivity, 有助于帮助自己了解Mobx的运行原理.
  4. 如果你的组件没有按照你的预期重新渲染, 尝试从这里找一下问题.
  5. Mobx不会跟踪异步访问的数据.
  6. 使用@actionrunInAction还有助于帮助我们调试代码.

参考文章:

  1. Mobx设计思想与实现
  2. 从Mobx的action探探js的异步
  3. mobx-react-issue-505
  4. 深入浅出 React ——自动批处理特性