目录
预备知识
计算器示例
概念
在软件开发中有一句名言:共享的可变状态是万恶之源,而函数式编程正是能够彻底解决共享状态。函数式编程本质上也是一种编程范式(Programming Paradigm ),其代表了一系列用于构建软件系统的基本定义准则;它强调避免使用共享状态(Shared State )、可变状态(Mutable Data )以及副作用(Side Effects ),整个软件系统由数据驱动,应用的状态在不同纯函数之间流动。与偏向命令式编程的面向对象编程而言,函数式编程其更偏向于声明式编程,代码更加简洁明了、更可预测,并且可测试性也更好;典型的函数式编程语言有 Scala、Haskell 等,而其编程思想在 Go、Swift 、 JavaScript、Python 乃至于 Java 中都有着广泛而深远的实践应用。
共享状态(Shared State )可以是存在于共享作用域(全局作用域与闭包作用域)或者作为传递到不同作用域的对象属性的任何变量、对象或者内存空间。在面向对象编程中,我们常常是通过添加属性到其他对象的方式共享某个对象。共享状态问题在于,如果开发者想要理解某个函数的作用,必须去详细了解该函数可能对于每个共享变量造成的影响。往往多个并发请求会导致的数据一致性错乱也就是触发所谓的竞态条件(Race Condition ),而不同的调用顺序可能会触发未知的错误,这是因为对于共享状态的操作往往是时序依赖的。
纯函数指那些仅根据输入参数决定输出并且不会产生任何副作用的函数。纯函数最优秀的特性之一在于其结果的可预测性:
var z = 10;function add(x, y) { return x + y;}console.log(add(1, 2)); // prints 3console.log(add(1, 2)); // still prints 3console.log(add(1, 2)); // WILL ALWAYS print 3复制代码
副作用指那些在函数调用过程中没有通过返回值表现的任何可观测的应用状态变化,常见的副作用包括但不限于修改任何外部变量或者外部对象属性、在控制台中输出日志、写入文件、发起网络通信、触发任何外部进程事件、调用任何其他具有副作用的函数等。在函数式编程中我们会尽可能地规避副作用,保证程序更易于理解与测试。Haskell 或者其他函数式编程语言通常会使用 来隔离与封装副作用。在绝大部分真实的应用场景进行编程开始时,我们不可能保证系统中的全部函数都是纯函数,但是我们应该尽可能地增加纯函数的数目并且将有副作用的部分与纯函数剥离开来,特别是将业务逻辑抽象为纯函数,来保证软件更易于扩展、重构、调试、测试与维护。这也是很多前端框架鼓励开发者将用户的状态管理与组件渲染相隔离,构建松耦合模块的原因。不可变对象(Immutable Object )指那些创建之后无法再被修改的对象,与之相对的可变对象(Mutable Object )指那些创建之后仍然可以被修改的对象。
const a = Object.freeze({ foo: "Hello", bar: "world", baz: "!"});a.foo = "Goodbye";// Error: Cannot assign to read only property 'foo' of object Object复制代码
函数式编程倾向于重用一系列公共的纯函数来处理数据,而面向对象编程则是将方法与数据封装到对象内。这些被封装起来的方法复用性不强,只能作用于某些类型的数据,往往只能处理所属对象的实例这种数据类型。而函数式编程中,任何类型的数据则是被一视同仁,譬如map()
函数允许开发者传入函数参数,保证其能够作用于对象、字符串、数字,以及任何其他类型。JavaScript 中函数同样是一等公民,即我们可以像其他类型一样处理函数,将其赋予变量、传递给其他函数或者作为函数返回值。而高阶函数(Higher Order Function )则是能够接受函数作为参数,能够返回某个函数作为返回值的函数。
const add10 = value => value + 10;const mult5 = value => value * 5;const mult5AfterAdd10 = value => 5 * (value + 10);复制代码
引用自:
ES2015 Modules
JavaScript 模块规范领域群雄逐鹿,各领风骚,作为 ECMAScript 标准的起草者 TC39 委员会自然也不能置身事外。ES2015 Modules 规范始于 2010 年,主要由 主导;随后的五年中 David 还参与了 asm.js,emscription,servo,等多个重大的开源项目,也使得 ES2015 Modules 的设计能够从多方面进行考虑与权衡。而最后的模块化规范定义于 2015 年正式发布,也就是被命名为 ES2015 Modules。我们上述的例子改写为 ES2015 Modules 规范如下所示:
// file lib/greeting.jsconst helloInLang = { en: "Hello world!", es: "¡Hola mundo!", ru: "Привет мир!"};export const greeting = { sayHello: function(lang) { return helloInLang[lang]; }};// file hello.jsimport { greeting } from "./lib/greeting";const phrase = greeting.sayHello("en");document.write(phrase);复制代码
ES2015 Modules 中主要的关键字就是 import
与 export
,前者负责导入模块而后者负责导出模块。完整的导出语法如下所示:
// default exportsexport default 42;export default {};export default [];export default foo;export default function () {}export default class {}export default function foo () {}export default class foo {}// variables exportsexport var foo = 1;export var foo = function () {};export var bar; // lazy initializationexport let foo = 2;export let bar; // lazy initializationexport const foo = 3;export function foo () {}export class foo {}// named exportsexport { foo };export { foo, bar };export { foo as bar };export { foo as default };export { foo as default, bar};// exports fromexport * from "foo";export { foo } from "foo";export { foo, bar} from "foo";export { foo as bar } from "foo";export { foo as default } from "foo";export { foo as default, bar } from "foo";export { default } from "foo";export { default as foo } from "foo";复制代码
相对应的完整的支持的导入方式如下所示:
// default importsimport foo from "foo";import { default as foo } from "foo";// named importsimport { bar } from "foo";import { bar, baz } from "foo";import { bar as baz } from "foo";import { bar as baz, xyz} from "foo";// glob importsimport * as foo from "foo";// mixing importsimport foo, { baz as xyz } from "foo";import * as bar, { baz as xyz } from "foo";import foo, * as bar, { baz as xyz } from "foo";复制代码
ES2015 Modules 作为 JavaScript 官方标准,日渐成为了开发者的主流选择。虽然我们目前还不能直接保证在所有环境(特别是旧版本浏览器)中使用该规范,但是通过 Babel 等转化工具能帮我们自动处理向下兼容。此外 ES2015 Modules 还是有些许被诟病的地方,譬如导入语句只能作为模块顶层的语句出现,不能出现在 function
里面或是 if
里面:
if (Math.random() > 0.5) { import './module1.js'; // SyntaxError: Unexpected keyword 'import'}const import2 = (import './main2.js'); // SyntaxErrortry { import './module3.js'; // SyntaxError: Unexpected keyword 'import'} catch(err) { console.error(err);}const moduleNumber = 4;import module4 from `module${moduleNumber}`; // SyntaxError: Unexpected token复制代码
并且 import 语句会被提升到文件顶部执行,也就是说在模块初始化的时候所有的 import
都必须已经导入完成:
import './module1.js';alert('code1');import module2 from './module2.js';alert('code2');import module3 from './module3.js';// 执行结果module1module2module3code1code2复制代码
并且 import
的模块名只能是字符串常量,导入的值也是不可变对象;比如说你不能 import { a } from './a'
然后给 a 赋值个其他什么东西。这些设计虽然使得灵活性不如 CommonJS 的 require,但却保证了 ES6 Modules 的依赖关系是确定(Deterministic)的,和运行时的状态无关,从而也就保证了 ES6 Modules 是可以进行可靠的静态分析的。对于主要在服务端运行的 Node 来说,所有的代码都在本地,按需动态 require 即可,但对于要下发到客户端的 Web 代码而言,要做到高效的按需使用,不能等到代码执行了才知道模块的依赖,必须要从模块的静态分析入手。这是 ES6 Modules 在设计时的一个重要考量,也是为什么没有直接采用 CommonJS。此外我们还需要关注下的是 ES2015 Modules 在浏览器内的原生支持情况,尽管我们可以通过 Webpack 等打包工具将应用打包为单个包文件。
引用
目标效果
在线示例
功能描述
- 点击
数字
后,顶部会有一栏显示待计算的数字。 - 点击
加减乘除
后,顶部会有一排小字,用来显示计算过程。 - 点击
等于
后,会清空计算过程并且顶部会显示计算结果。 - 点击
后退
后,会从右往左删除一位待计算的数字。 - 点击
清除
后,会初始化输出栏和计算过程。
模块分析
计算过程
会显示已输入的数字和运算符
输出栏
数字键盘
0 - 9
包括 .
功能键盘
加减乘除
清除
等于
后退
编程思路
计算过程分析
- 这其实是一个历史记录的功能,只是没有
撤销
和重做
。 - 历史记录是一个
栈结构的数据
,这里可以用数组
来表示。 - push时机:两次点击操作类型不同时,比如上一次点击的是数字,下次点击的是加号。
- 运算符覆盖:修改数组最后一位的值。
输出栏分析
- 纯展示功能,给什么数据就显示什么。
数字键盘分析
- 数据的输入点,会影响
输出栏
模块。 - 创建一个变量用来储存临时输入的数据
- 这是一个有副作用的功能,要小心对待
功能键盘分析
- 计算之前必须要先有数字
- 计算之后在点击
数字
按钮会重置待计算数字的显示 - 这是一个有副作用的功能,要小心对待
index.html
在项目目录下创建index.html
,添加下面的内容
计算器 复制代码
说明
本教程将使用组件化的思维来实现示例,传统的实现方式不再赘述。此说明只在这里说明一次,后面不会再提。
模块效果
创建文件夹
在项目目录下创建一个名为process
的文件夹
index.css
在process
文件夹下创建一个index.css
文件
.process { /* 高度 */ height: 40px; /* 用于文本居中 */ line-height: 40px; /* 字体大小 */ font-size: 12px; /* 文字颜色 */ color: #666; /* 文本向右对齐 */ text-align: right;}复制代码
index.js
在process
文件夹下创建一个index.js
文件
/** * 计算过程显示栏组件 * @param text 显示的文本 */const processComponent = (text) => `${text}`;function renderProcessComponent(text) { // 先获取计算过程的占位元素 const elem = document.getElementById('process'); // 将计算过程的HTML填充到占位元素中 elem.innerHTML = processComponent(text); console.log('渲染计算过程组件完成,输出的值是', text);}复制代码
更新index.html
在</head>
标签前添加
复制代码
在</body>
标签前添加
复制代码
接着上一行代码,换行添加
复制代码
最终index.html
计算器 复制代码
模块效果
创建文件夹
在项目目录下创建一个名为output
的文件夹
index.css
在output
文件夹下创建一个index.css
文件
.output { /* 上边距0,右边距16px,下边距60px,左边距0(最后一个为0的话可以省略不写) */ padding: 0 16px 60px; /* 字体大小 */ font-size: 33px; /* 文字颜色 */ color: #000; /* 文本向右对齐 */ text-align: right;}复制代码
index.js
在output
文件夹下创建一个index.js
文件
/** * 输出栏组件 * @param text 显示的文本 */const outputComponent = (text) => `${text}`;function renderOutputComponent(text) { // 先获取输出栏的占位元素 const elem = document.getElementById('output'); // 将输出栏的HTML填充到占位元素中 elem.innerHTML = outputComponent(text); console.log('渲染输出栏组件完成,输出的值是', text);}复制代码
更新index.html
在</head>
标签前添加
复制代码
在<script src="./process/index.js"></script>
代码后面添加
复制代码
在renderProcessComponent('');
后面添加
// 渲染输出栏,初始化为'0',这里不初始化为数字0的原因后面再提renderOutputComponent('0');复制代码
最终index.html
计算器 复制代码
模块效果
这里我们把数字键盘和功能键盘合在一起做
创建文件夹
在项目目录下创建一个名为keyboard
的文件夹
index.css
在keyboard
文件夹下创建一个index.css
文件
#keyboard { /* 包裹键盘组件的容器,内边距3px */ padding: 3px;}.keyboard-row { /* 显示模式为弹性布局 */ display: flex; /* 竖轴方向上居中 */ align-items: center; /* 横轴方向两端对齐 */ justify-content: space-between; /* 上下内边距3px */ padding: 3px 0;}.keyboard-button { /* 每个按钮平分横轴上的宽度 */ flex: 1; /* 文字水平居中 */ text-align: center; /* 文字大小 */ font-size: 18px; /* 文字颜色 */ color: #000; /* 上下内边距20px,相当于使文字垂直居中 */ padding: 20px 0; /* 这里可以起到一个左内边距为3px的作用,因为边框颜色和主体背景相同,所以看上去是一个间距 */ border-left: 3px solid #e6e6e6; /* 这里可以起到一个右内边距为3px的作用,因为边框颜色和主体背景相同,所以看上去是一个间距 */ border-right: 3px solid #e6e6e6; /* 这里为了简化实现,把所有按钮都设置为了白色 */ background: #fff; /* 将鼠标手势设置为手指,表示这是一个可点击的按钮 */ cursor: pointer;}复制代码
index.js
在keyboard
文件夹下创建一个index.js
文件
/** * 键盘的按键映射,这里用了一个二维数组来实现 * 空白按钮使用了一个全角的空格,目的是为了让div元素拥有高度 */const keyMap = [ [' ', '清除', '后退', '/'], ['7', '8', '9', '*'], ['4', '5', '6', '-'], ['1', '2', '3', '+'], [' ', '0', '.', '=']];/** * 键盘按钮组件 * onclick事件传递了一个this,表示执行事件的元素 * @param props { * // 按钮的文本 * label: string; * // 按钮点击事件方法名 * onClick: string * } */const keyboardButtonComponent = (props) => ``;/** * 键盘组件 * 代码中的join是因为keyMap返回的是一个数组,然后渲染组件需要的是一个字符串拼接的html * 如果不做join(' ')的操作,会自动使用join(),join的默认参数是',' * @param props { * // 按钮点击事件方法名 * onClick: string * } */const keyboardComponent = (props) => keyMap.map((row) => `${row.map((label) => keyboardButtonComponent({ label: label, // 把点击事件往下传递,传给keyboardItemComponent onClick: props.onClick }) ).join(' ')}`).join(' ');/** * 键盘组件的渲染 * @param props { * // 按钮点击事件方法名 * onClick: string * } */function renderkeyboardComponent(props) { // 先获取键盘组件的占位元素 const elem = document.getElementById('keyboard'); // 将键盘组件的HTML填充到占位元素中 elem.innerHTML = keyboardComponent(props); console.log('渲染键盘组件完成');}复制代码
更新index.html
在</head>
标签前添加
复制代码
在<script src="./output/index.js"></script>
代码后面添加
复制代码
在renderOutputComponent('0');
后面添加
// 渲染键盘组件,传递点击键盘按钮的事件renderkeyboardComponent({ onClick: 'onKeyboardClick'});复制代码
在init();
前面添加一个空的函数
function onKeyboardClick(e) {}复制代码
最终index.html
计算器 复制代码
小结
至此计算器的UI算是完成了,剩下的工作就是添加按钮事件了。
最终index.html
计算器 复制代码
更新内容
2个控制变量
// 计算过程let process = [];// 输出栏显示的值,默认值为0let output = '0';复制代码
2个函数
// 对计算过程求和function processSum() { // 计算过程至少有3次操作才能求和 if (process.length > 2) { // slice方法获取数组的副本,不对原数组进行更改, process.length - 1是为了过滤最后一位的运算符 // reduce因为乘法和除法对运算有优先级,所以要按照数组顺序依次求和 const result = process.slice(0, process.length - 1).reduce((prev, cur) => { // 这里相当于是对cur做一个是否是数字的判断 try { return eval(prev + cur); } catch (err) { return prev + cur; } }); return result; } else { // 对第一次操作就是等号时做处理 return output; }}// 键盘点击事件function onKeyboardClick(e) { // 获取按钮的值 const label = e.innerText; // 计算结果, 默认为输出栏的输出 let result = output; switch (label) { case ' ': // 过滤空白的按钮,不做任何操作 break; case '.': // 判断有没有重复点击小数点按钮,有小数点存在就不再拼接 output += output.indexOf('.') >= 0 ? '' : '.'; break; case '+': case '-': case '*': case '/': // 把待计算的数字加入计算过程 process.push(output); // 把运算符加入计算过程 process.push(label); // 将输出栏的内存记录状态初始化为'0' output = '0'; // 重新渲染输出栏组件,把计算结果作为显示的值 renderOutputComponent(processSum()); // 重新渲染计算过程组件 renderProcessComponent(process.join(' ')); break; case '=': // 至少要有2个操作存在的情况下才能使用等号求值 if (process.length >= 2) { process.push(output); process.push(label); result = processSum(); } // 因为返回结果是一个数字,而indexOf需要字符串 output = result.toString(); // 重置计算过程 process = []; renderOutputComponent(result); renderProcessComponent(''); break; case '后退': // 从末端减少一位,如果越界了就初始化为'0' output = output.substr(0, output.length - 1) || '0'; renderOutputComponent(output); break; case '清除': output = '0'; process = []; renderOutputComponent(output); renderProcessComponent(''); break; default: // 最后一种情况就是点击的是数字 // 如果输出栏上第一位是0,那么就覆盖掉,否则就拼接 output = output.indexOf('0') === 0 ? label : output + label; // 重新渲染输出栏组件 renderOutputComponent(output); break; }}复制代码
总结
计算器的求值算法不强求理解,这个示例主要展示了:
- 怎么编写一个纯组件
- 怎么让组件工作
- 怎么让多个组件拼装在一起
- 数据驱动的简单展示
- 怎么控制副作用