概念
我们先来了解一下副作用的相关概念。
在计算机科学中,函数副作用指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响 -- 维基百科
副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。-- 知乎
可以看到两者都说明了副作用是函数调用过程中除了(返回值以外)对外部上下文的影响。
生活中的副作用
原理都是比较抽象的,但俗话说编程来源于生活,我们可以通过以下几个生活中的例子来理解类比编程中的副作用。
-
药物的副作用:例如在服用感冒药时,我们的目的是治疗感冒。但是由于药物的副作用,我们产生了身体无力的不好症状。
仔细分析:
- 治病过程 -- 一个函数的执行
- 吃药 -- 参数
- 治好感冒 -- 返回值
- 身体无力 -- 副作用
可以看到治疗感冒这一过程带来的副作用是身体无力。那么在治疗过程中我们干其他事情将受到身体无力的副作用而效率变低,这是一个对于我们不利的副作用,我们应该避免。
-
吃KFC:假设小黑有一张KFC的抵用券。到了中午他特别饿,想要找吃的填饱肚子。恰好手头有一张KFC的抵用券,填饱肚子的同时顺便用掉一张抵用券。
仔细分析:
- 填饱肚子过程 -- 一个函数执行
- 吃KFC -- 参数
- 饱了 -- 结果
- 消耗一张抵用券 -- 副作用
可以看到吃KFC填饱肚子这一过程的副作用是消耗一张抵用券。诚然这是我们愿意看到的结果:既填饱了肚子又获得了优惠。这是一个对我们有利的副作用,换言之我们利用了副作用。
js 中的副作用
生活到编程的映射
回到编程本身来看我们可以根据KFC的例子写出如下代码:
let voucher = 1; // 假设小黑有一张抵用券
let isHungry = true; // 处于饥饿状态
/* @param {string} shopName 店名
* @return {boolean} 是否饥饿 */
function eat(shopName) {
if(shopName === 'KFC' && voucher > 0) {
voucher--;
}
return false; // 吃完,不饿了
}
isHungry = eat('KFC'); // 吃一次KFC
复制代码
显而易见,只要我们执行一次 eat('KFC');
那么抵用券就会-1,这也符合定义中提到的:在函数调用时(除返回值外)对外界环境产生了影响。
js 中两种产生副作用的方式
基于个人的理解,我把 js 中的副作用分为两类:a. 由作用域链和闭包产生的副作用 b. 改变函数入参产生的副作用。
-
作用域和闭包产生的副作用。
作用域和闭包可以说是 js 中很有意思的特性了,他们带来的编程特性有:
-
函数体可以访问其定义时所处的外部上下文中所声明的变量 (作用域);
这一点已在上面的KFC一例中体现出来了( eat函数中可以访问, 修改全局作用域下的voucher变量 )。
-
在外界间接使用函数中变量时,函数执行产生的上下文不会被销毁 (闭包);
这里可以通过一个迭代器的例子来说明闭包所带来的副作用。
// items 被迭代的数组 返回用于迭代的next函数 function createIterator(items) { let i = 0; function next() { // 如果达到数组长度,则已经完成迭代 let done = i >= items.length; // 达到数组长度返回undefined,否则返回下一个值 let value = !done ? items[i++] : undefined; return { done: done, value: value }; } return { next }; } const iterator = createIterator([1,2]); iterator.next(); // "{ value: 1, done: false }" iterator.next(); // "{ value: 2, done: false }" iterator.next(); // "{ value: 3, done: true }" 复制代码
这一例子中可以说
next()
函数 和i
构成了闭包,同时也产生了副作用:每执行一次next()
i
就会加 1。 -
-
改变函数入参产生的副作用
这一点应该对于大多数语言都适用。当函数的入参是引用数据类型时,我们修改该数据的属性值必然会影响到外部环境。下面是一个简单的例子来说明:
const black = { name: '小黑', weight: 120, isHungry: true }; function eat(person) { person.weight++; return false; } black.isHungry = eat(black); 复制代码
这里的 person.weight 的修改就是通过入参的应用数据类型产生的副作用。
当然js中有“天然”的例子 数组方法
pop
。以下是一个将数组最后一位移到第一位的例子:function lastToFirst(arr) { // 第一种 const last = arr.pop(); arr.unshift(last); return arr; } function lastToFirstOptimize(arr) { // 第二种 优化 const temp = [...arr]; const last = temp.pop(); temp.unshift(last); return temp; } 复制代码
诚然在第一种方法中其改变了外部环境的数组,它的好处在于如果我们确实只需要改变数组顺序,则相对方便可以省去赋值步骤。
第二种方法则是不改变原数组而是生成一个改变顺序后的结果。二者各有利弊。
js 中副作用的总结
js 中的副作用很特殊,划分的两种情况从本质上看是:
- 对于函数声明时所处环境的影响;
- 对于函数执行时所处环境的影响。
从数据操作函数来看,改变要操作数据本身,应该允许副作用;而在需要保存原始数据时应该避免副作用。 对于副作用的处理和使用是见仁见智的,是依据实际逻辑情况来定的,它既带来了简便的代码风格,也带来了系统状态变化的不易确定。
Vue 中的副作用
数据驱动视图模型
提到 vue 和 react 不得不提的是他们共有的特点:“数据驱动视图”。那么站在副作用的角度看他们的逻辑模型大概是这样的:
可以看到通过数据驱动视图中,vue可以运用的副作用有computed
、watch
等等,react有useEffect
等等。
当然这些副作用通常做的是再次改变数据来驱动视图变化。例如 vue2 中使用 computed 来进行数据倍数的关联:
<template>
<div>
<div> {{ count }} </div>
<div> {{ double }} </div>
<button @click="add">count加1</button>
</div>
</template>
<script>
export default {
data() {
retrun { count: 1 }
},
methods: {
add() { this.count++; }
}
computed: {
double() { return this.count * 2; }
}
}
</script>
复制代码
以上代码当我们点击按钮时会调用 add 函数让 count 增加,过程中产生了副作用 double 会返回一个双倍的 count,因此视图变化的过程中 computed 就成为了副作用。
数据改变产生的副作用模型
然而上面这个模型并不是最抽象的模型。因为可以看到 computed、watch、useEffect、视图变化 均为数据改变所产生的副作用。
因此我们的模型可以是这样的:
reactivity API
接下来就为大家介绍 vue3 中对以上副作用模型抽象出的库 reactivity API,它是完全可以独立运行于浏览器、node环境中的, 可以基于其的副作用原理来自定义render函数来做自己的视图渲染。
代码示例:
const { reactive, effect } = require('@vue/reactivity');
const countRef = reactive({ value: 10 }); // 创建一个响应式的数据,参数必须是对象
effect(() => { // 定义数据改变时的副作用函数
console.log(countRef.value);
});
countRef.value = 20; // 当设置数值时会自动地打印出设置的值,即执行了副作用函数
countRef.value = 30;
复制代码
可以看到当effect调用时和数据改变的时候都会触发 console.log(countRef.value);
这也完美的呈现了上面数据变化的副作用的图解过程。
执行结果如下:
有了上面代码,我们能很容易想到如何实现数据驱动视图, 代码如下:
<body>
<div id="app">
{{count}}
<button onclick="add">加一</button>
</div>
</body>
<script type="module">
import { reactive, effect } from './node_modules/@vue/reactivity/dist/reactivity.esm-browser.js';
// 定义一个简单的render
const app = document.getElementById('app');
const template = app.innerHTML;
function render() {
const btnIdEventMap = {};
// 将{{属性名}} 替换为对应的响应式数据
const newHtml = template.replace(/\{\{(\w+)\}\}/g, (match, propName)=>{
return data[propName];
})
// 为有点击事件的元素打上标记id
.replace(/onclick=\"(\w+)\"/g, (match, eventName)=>{
const eventCount = Object.keys(btnIdEventMap).length;
btnIdEventMap[`btn${eventCount}`] = eventName;
return match + ` id="btn${eventCount}" `;
});
//重新渲染
app.innerHTML = newHtml;
// 添加点击事件
for(const btnId in btnIdEventMap) {
console.log(window[btnIdEventMap[btnId]]);
console.log(document.getElementById(btnId));
document.getElementById(btnId).onclick = window[btnIdEventMap[btnId]];
}
}
// 使用render完成渲染
const data = reactive({count: 0});
window.add = () => {
data.count = data.count + 1;
}
effect(render); // 调用render函数渲染视图
</script>
复制代码
React 中的副作用(useState / useEffect)
首先简单了解一下以上两者的概念及用途。
useState
就是一个 Hook (等下我们会讲到这是什么意思)。通过在函数组件里调用它来给组件添加一些内部 state。React 会在重复渲染时保留这个 state。useState
会返回一对值:当前状态和一个让你更新它的函数,你可以在事件处理函数中或其他一些地方调用这个函数。
简而言之:useState 会提供一次渲染视图的数据 和 改变数据的方法。
useEffect
就是一个 Effect Hook,给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMount
、componentDidUpdate
和 componentWillUnmount
具有相同的用途,只不过被合并成了一个 API。
简而言之:useEffect 提供了 state 发生改变,以及组件卸载等“事项”的副作用。
我们来根据具体例子来实践一下。
首先是 useState:
const [count, setCount] = useState(0);
const [greet, setGreet] = useState('欢迎!');
const setCountAndGreet = () => {
setCount(10);
setGreet('welcome!');
}
console.log('触发渲染');
return (
<div>
{count} -- {greet}
<button onClick={() => setCount(1)} >按钮 1 </button>
<button onClick={() => setCount((value) => value)} >按钮 2</button>
<button onClick={setCountAndGreet} >按钮 3</button>
</div>
)
}
复制代码
我们来分析这 3 个按钮的点击结果
- 按钮1:调用
setCount
后 react 产生了一个渲染视图的副作用,重新执行了我们定义的函数 打印一次 '触发渲染'。 - 按钮2:调用
setCount
后 react 内部判断设置的新值与旧值相同则并不触发 渲染视图的副作用 不会打印 '触发渲染'。 - 按钮3:当一次宏任务(点击事件)同时触发多个 set 函数是都会记录一次 action 到对应的新的 state 对象上并形成 操作队列,进入微任务后根据 state 变化重新构建 react-element (也就重新执行我们定义的函数得到的结果)、并经处理生成新的 fiber 树,最终更新视图。
接下来分析一下 useEffect 执行时机:
const App = () => {
const [count, setCount] = useState(0);
const [greet, setGreet] = useState('欢迎!');
const countInDOM = document.getElementById('count')?.innerHTML;
console.log(`触发函数时 count 为 ${countInDOM}`);
useEffect(() => {
const countInDOM = document.getElementById('count')?.innerHTML;
console.log(`effect 触发时 count 为 ${countInDOM}`);
});
useEffect(() => {
console.log('触发欢迎语改变的副作用');
}, [greet]);
return (
<div >
<div id="count" >{count}</div>
<div>{greet}</div>
<button onClick={() => setCount(4)} >按钮 4</button>
<button onClick={() => setGreet('welcome!')} >按钮 5</button>
</div>
)
}
复制代码
单独点击按钮 4 时, 结果如下:
- 打印 '触发函数时 count 为 0';
- 渲染视图;
- 打印 'effect 触发时 count 为 4',这次操作并未改变 greet 因此不会触发对应的副作用因此不打印 '触发欢迎语改变的副作用';
单独点击按钮 5 时, 结果如下:
- 打印 '触发函数时 count 为 0';
- 渲染视图;
- 打印 '触发欢迎语改变的副作用';
因此我们可以简单分析出 react 在数据改变后产生的副作用有:
- 重新执行我们定义的函数(产生了新的闭包);
- 重新构建 fiber 树;
- 触发 渲染视图;
- 触发 effect 定义的副作用;
结语
从以上两大前端框架的分析中我们能看到他们的编码风格是如此的相似,而其中内部的实现方式又如此不同,可谓条条大路通罗马。
副作用的应用无疑给前端编码带来了便利,因为利用它可以帮助我们完成数据驱动视图的理念,在大部分情况下避免了繁琐的 DOM 操作。在如今这个前端框架盛行的时代,副作用在前端的日常编码下可以说是无时无刻都存在的,它扮演者双刃剑的角色,不论是作为框架的设计者以及使用者,都应该 确保副作用是清晰的可追溯的,才能让我们的代码 under-control。