2019.10.3参加了我张立理厂(知乎:张立理)大神的分享《Structuring Frontend Code》,感觉很有收获,结合他的PPT,把自己的收获整理成为笔记,日常温习,与大家分享。
逻辑&组织
到底要表达什么样的逻辑,将逻辑到函数一一映射。
if
里面的判断的条件是什么?可以使用函数来表达。函数比注释更有用。
编码是从自然逻辑到程序逻辑的映射过程,其本质是对问题的分解再组合,非复杂度改善的性能往往并没有那么重要。
也就是说,有些时候对于可维护性、可读性的提高,带来的遍历次数、复杂度的提升,对于前端来说,是可以忽略的。因为前端的数据量往往不会很大,从DOM操作、渲染的回流与重绘角度对前端性能的提升更加显著。
所以,可以牺牲一部分性能,换取可读性和可维护性。
以简为道,简以组合
下面的代码,是我现在的水平,实现的目的是做了一个缓存,如果有缓存的时候就走缓存,没有的时候会重新计算,并存到缓存中:
const evaluationCache = {};
const evaluate = expression => {
if (!evaluationCache[expression]) {
evaluationCache[expression] = // 几十行代码;
}
return evaluationCache[expression];
};
可以进行优化,使用高阶函数,将几十行代码
抽离出来,便于测试:
const evaluationCache = {};
const cacheEvaluationResult = evaluate => expression => {
if (!evaluationCache[expression]) {
evaluationCache[expression] = evaluate(expression);
};
}
return evaluationCache[expression];
const evaluate = expression => ...;
const evaluateWithCache = cacheEvaluationResult(evaluate);
在此基础上,可以发现,为函数添加缓存,实际上是与业务无关的功能,完全可以抽离成为公共函数memoize
(一个很多公共库中都有的函数,比如lodash.memorize
,有时间可以学习一下它的源码)
const memoize = fn => {
const cache = new Cache({algorithm: 'lfu', maxSize: 20});
return (...args) => {
const cachedResult = cache.find(previous = > shallowEquals(previous, args));
if (cachedResult.exists) {
return cachedResult.value;
}
const result = fn(...args);
cache.add(args, result);
};
return result;
};
const evaluateWithCache = memoize(evaluate);
const getContainerWithCache = memoize(getContainer);
组合以传播
有下面这样一个例子,代码的作用是,获得生日在指定时间段的用户的集合,(以我现在的水平)代码如下:
// Without cache
const getDateRange = months => ...;
const filterBirthdays = (dateRange, users) => users.map(...);
const getRecentBirthday = (months, users) => {
const dateRange = getDateRange(months);
return filterBirthdays(dateRange, users);
};
如果想加缓存,我可能要在getRecentBirthday
里面对getDateRange
和filterBirthdays
进行修改,但是更好的做法是结合桑上面的memoize
函数,创建一个createRecentBirthFilter
工厂函数,这个工厂函数的两个参数就是获得日期范围和筛选用户的两个函数,这样的话,可以随意更改传入的两个函数(是否添加缓存的版本)
// With cache
const createRecentBirthFilter = (getDateRange, filterBirthdays) => (months, users) => {
const dateRange = getDateRange(months);
return filterBirthdays(dateRange, users);
};
const getRecentBirthdaysWithCache = createRecentBirthFilter(
memoize(getDateRange),
memoize(filterBirthdays)
);
上面的例子说明:始终将逻辑拆解到最简,将会获得更多的可能性。将业务无关的逻辑提取出来,争取复用的最大化。
优先使用组合
使用面向对象的继承,看起来很美好,对URL添加了前缀:
class AjaxBase {
// @protected
mapURL(requestURL) {
return '/api' + requestURL;
},
// @protected
request(method, apiURL, data) {
const url = this.mapURL(apiURL);
// ...
};
}
class PostRepository extends AjaxBase {
savePost(post) {
return this.request('POST', '/posts', post);
}
}
但是当API分版本控制,不同的请求使用不同的版本,那么会导致不同的子类都需要维护自己的mapURL
方法:
class PostRepository extends AjaxBase {
mapURL(requestURL) {
return '/api/v2' + requestURL;
}
}
class CommentRepository extends AjaxBase {
mapURL(requestURL) {
if (requestURL.includes('draft')) {
return '/api/v3' + requestURL;
}
return '/api/v2' + requestURL;
}
}
可以使用组合来代替继承,虽然本身的实现有些复杂,但是对于代码的可维护性提高确实非常有意的:
class URLMapperV2 {
map(url) {
return '/api/v2' + url;
}
}
class URLMapperV3 {
// ...
}
class URLMapperComposite {
constructor(keywordMapping) {
this.keywordMapping = keywordMapping;
}
map(url) {
const actualMapper = keywordMapping.find(([prefix]) = > url.includes(prefix));
return actualMapper.map(url);
}
}
const commentURLMapper = new URLMapperComposite(
[
['drafts', new URLMapperV3()],
['', new URLMapperV2()]
]
);
实际上使用函数式变成配合组合,是现在更加主流和受推崇的方法,比如React的Hooks API,使用函数进行组合的话,代码会更精简一些:
const mapV2 = url => '/api/v2' + url;
const mapV3 = url => '/api/v3' + url;
const mapComposite = keywordMapping => url => {
const map = keywordMapping.find(([prefix]) => url.includes(prefix));
return map[1](url);
};
const mapCommentURL = mapComposite(
[
['drafts', mapV2()],
['', mapV3()],
]
)
还有这样的例子,如果使用继承,创建了一个TextBox
基类,可以通过继承它得到而来带前缀的TextBoxWithPrefix
、可缩放的ResizableTextBox
、带后缀的TextBoxWithSuffix
,但是随着业务发展,如果需要一个带前缀且可缩放的输入框,继承就有些无能为力了,必然会带来冗余的代码
但是如果换用一种思路,使用组合的方式,每个函数实现为当前的TextBox
添加一种功能,也是通过高阶函数完成:
class TextBox {
// ...
}
const withPrefix = TextBox => class extends TextBox {
// ...
};
const withSuffix = TextBox => class extends TextBox {
// ...
};
const draggable = TextBox => class extends TextBox {
// ...
};
withPrefix(withSuffix(TextBox));
draggable(withPrefix(TextBox));
draggable(withSuffix(TextBox));
draggable(withPrefix(withSuffix(TextBox)));
可以看出来,组合提供更小的复用粒度和更灵活的按需调整。
从编程到编织
有一些基础的功能,通常是与业务无关的功能,比如日期时间的处理,完全没有必要自己从零开始造轮子,社区里有现成的、更加健壮的工具可以使用,从而让我们把主要的关注点放到业务逻辑上。
这一点也是最近开始有感触,以前实现功能,什么都想自己写,现在也开始逐渐学着使用现有的工具,但是有一些同事还没有这样的概念,处理事件 ,自己写了一堆逻辑,负责且脆弱,依赖的某些方法出现了兼容性问题,导致了Bug。
尝试使用编织而不是编写的方式看待代码,逐渐培养对代码拆解的直觉。
我们工作的目的是什么,我们工作不是为了写代码,而是为了通过写代码来实现需求、解决问题,所以不要纠结于自己写了多少代码,而是解决了什么样的问题,更高效的实现了需求。一切都要从这一点出发。
关于纯函数
什么是纯函数:
- 不依赖外部作用域内可变化的状态
- 不对任何环境进行修改操作
- 相同的输入始终得到相同的输出
什么情况会到导致函数不纯?
- 使用
Math.random()
- 使用
new Date()
- 使用除了空函数意外的没有返回值的函数
- 异步函数
- 修改全局/作用于内变量的函数
- 修改参数
纯函数具备稳定性,易于调试、测试,纯函数调用纯函数,结果还是纯函数,纯函数调用非纯函数,结果是非纯函数
由于前端必然要操作DOM,所以不不可能完全是纯函数,所以尽量增多纯函数,减少或者标记非纯函数。在正常的函数中引入副作用容易隐藏BUG的源头。例如下面的函数:
const branches = data.branches.map(ref => {
if (ref.isDefaultBranch) {
localStorage.set(`baseBranch: ${repo}`, encodeURIComponent(ref.name));
localStorage.set(repo, encodeURIComponent(ref.name));
}
ref.isBranch = true;
ref.commit = ref.commitId;
return ref;
});
这显然不是一个纯函数,因为:
- 对
localStorage
进行了写操作 - 在
map
方法中对原数组进行了修改
如何优化这样的函数呢?
- 在
map
函数中不引入副作用(包括some
/reduce
等方法),不应该修改原数组 - 最小化需要副作用的内容
- 明确标记副作用(下面的代码使用了
for...of
来代替forEach
进行遍历,用来标记有副作用的内容)
副作用是BUG的种子,使用良好的压缩与隔离让更多的代码稳定可测。
异步与乱序
网络的存在注定前端与异步不可分离,而异步会引入静态,错误的静态处理容易导致状态的错误。最简单的例子就是,分别点击3
/2
/1/
三个按钮,发送请求获取数据,将结果渲染在DOM上,但是由于网速的不稳定,返回的结果顺序是1
/2
/3
,这样就会导致了错乱,按钮1
最后被点击,而现实的结果是按钮3
的请求结果:
<template>
<div class="main">
<p>结果:{{message}}</p>
<button @click="clickHandler(1)">1</button>
<button @click="clickHandler(2)">2</button>
<button @click="clickHandler(3)">3</button>
</div>
</template>
<script>
export default {
data() {
return {
message: '',
}
},
methods: {
async clickHandler(type) {
this.message = await this.fetch(type);;
},
fetch(time) {
return new Promise(resolve => {
setTimeout(resolve, time * 1000, time)
})
}
},
}
</script>
以前面试也会遇到过这种问题,最简单的做法就是在请求内部、结果返回后进行状态校对,其实也是利用了闭包,当时头条的面试官考察的时候我还一知半解
async clickHandler(type) {
this.selected = type;
const res = await this.fetch(type);
if (type !== this.selected) {
return;
}
this.message = res;
},
这种方法的好处就是简单直接,但是缺点也很明显,每一处都需要如此处理,重复编码量大,而且造成了资源浪费,例如上面的例子中,最先点击的按钮3
返回的结果被舍弃了,但如果我马上再点击3
,还需要重复发送请求。
所以可以使用资源池以及发布订阅的模式。在面试快手的时候被问到这个问题,想到使用资源池解决这个问题,但是当时被问到,如果连续两个相同的请求如何处理时,我想到的是增加一个pending
状态,但是依然无法解决问题。
一种解决方法就是使用发布订阅的模式,当我们请求资源时,不直接依赖于请求的结果或者回调函数,而是先进行订阅对应的事件,通过监听这个事件的返回值来获取响应结果。
本来想自己按照PPT上的源码,自己实现一个能够全局缓存、同时能够解决竞态的公共函数,结果发现比自己预想的要复杂。现在项目中最欠缺的是解决缓存的问题,再设置缓存失效时间,且如果对同一个URL的连续两次访问如何避免连续发送两次请求,这些是当前最需要解决的。关于竞态的问题目前还是在组件中单独解决吧。
下面的代码没有经过生产验证,也没有单元测试验证,所以可靠性没有保证。并且设计水平和设计思路都欠佳,希望看到的同学见谅并且予以指正。
// import axios from 'axios';
// mock axios
const axios = {
request({url: time}) {
console.log('HTTP!!!!');
return new Promise(resolve => {
setTimeout(resolve, time * 1000, time)
})
}
};
const requestFactory = () => {
// TODO: Vuex
const cache = {};
let current = null;
const flush = key => {
if (cache[key] && Array.isArray(cache[key].listeners)) {
cache[key].listeners.forEach(listener => {
listener(cache[key].value)
});
cache[key].listeners = [];
}
};
const add = (key, value) => {
cache[key].value = value;
cache[key].pending = false;
return value;
};
return {
visit({method = 'get', url, params = {}}) {
const key = [method, url, JSON.stringify(params)].join('_');
current = key;
// 缓存中已有值,从缓存取值
if (cache[key] && cache[key].value !== null) {
Promise.resolve(cache[key].value).then(() => flush(key));
return this;
}
// 无缓存,但已处于 pending 状态
if (cache[key] && cache[key].pending) {
return this;
}
// 无缓存,且未 pending
cache[key] = {value: null, listeners: [], pending: false};
cache[key].pending = true;
// 发送请求
axios.request({method, url, params}).then(value => add(key, value)).then(() => flush(key));
return this;
},
listen(cb) {
if (!cache[current]) {
throw new Error('Listen must be called after visit');
}
cache[current].listeners.push(cb);
}
}
};
const requestWithCache = requestFactory();
export default requestWithCache;
前端的挑战在于状态的持久性,持久的状态加上乱序的异步逐渐引入混乱。良好的设计有助于解决异步乱序。
避免数据的过早加工
对于加工数据,每个加工步骤都存在信息的丢失,大部分错误仅能在末端发现,过长的加工链导致发现的错误难以追溯具体的引入环节。
所以,应该复用加工的工具(函数),而不是加工的结果。
但是这也会带来性能的降低,可以引入缓存解决多次加工的性能冲击。(但是实际上我并不是太理解,引入了缓存,那么也就意味着复用了加工的结果,和前面不是冲突吗?)
重构代码
明确重构的边界,也就是明确我们重构的目标是什么,边界之外的代码坚决一行不改动,否则重构就会无边际的蔓延开来,不受控制。
明确重构的边界是目的,但是还需要足够的能力和技巧来完成,那就是明确具备传染性的技术(例如CSS Modules),使用丑陋的方式(例如允许一定的冗余的代码),拒绝重构传染到边界外部。
在重构过程中,使用git add
进行阶段性归档(比使用撤销或者IDE的历史记录更可靠)
编写安全的代码
前端代码的安全性一个重要的隐患就是XSS漏洞,最主要的引入方式之一就是直接使用了innerHTML
,解决方法:
- 使用自带转义的模板引擎;
- 使用白名单语法(例如Plain Text、BBCode、Markdown)
- 使用第三方的转义库(XSS)
- 使用CSP
详细的可以参考我的学习笔记网络基础07 HTTP安全
编写健壮的JavaScript
尽量早地抛出异常,尽量晚的捕获异常,但是不能不捕获,不处理无法处理的异常。对异常进行分类,实现自己业务相关的异常,封装时保留异常原始信息
const createError = (type, message, extraData, innerException) => {
const error = new Error(message);
return Object.assign(error, extraData, {type, innerException});
};
const toErrorString = error => {
const segments = [
error.message,
error.stack,
...[error.innerException ? ['Caused by:', toErrorString(error)] : []],
];
return segments.join('\n');
};
更多基础知识也可以参考我总结的笔记零散专题37 前端代码异常监控
代码评审
我一直希望有高水平的人能认真的评审我的代码,但是很可惜,到现在都没有这样的运气。
现在的工作有这样的机制和工具,但是同组的同学这方面的意识淡薄,只能我抽时间单方面的评审他们的代码,虽然自己也能从他们的代码中学到东西,但是还是觉得很遗憾。
在评审的时候,还不需要再去评审与ESLint重复的工作(所以提交之前的ESLint检查很重要),例如代码格式等,要评审的重点在于:
- 代码阅读的顺畅感
- 代码的重复性、复用性
- 业务逻辑的理解
- 关键设计的遵循度