在前端,你聊建模和分层,总会被人烦
过去两年,我遇到比较尴尬的情况,就是无法很好的解释建模和分层的总要性和便利性,因为前端领域关于这块的东西共同语言比较少,大家都是在专注于视图层的新技术研究,而在更广泛的编程范式、原则上,比较少去探讨,因此,每当我聊到这个东西的时候,无法快速的聊核心思想,必须在外围给听者建立场景,举例子,但是这些例子在日常前端开发中,又不是非得按你这套才能实现,所以听者往往带着自己的想象和经验先对例子进行脑海复盘,然后来评价所谓建模、分层都太复杂,这就是非常尴尬的场面,你还没有开始聊建模和分层本身,就已经在前期预热阶段被听者否定,那么,后续所讲的任何话都没有任何意义。
前端建模和分层是非常重要的,它本身并不复杂,也不会增加工作量,问题在于它所要求开发者具备的思维方式、理念是比较庞大的,因此往往被抵触。如果你在完成一个需求时,提交代码进行review,直接被对方打回来,理由是不符合我们的开发理念,你一定也会很生气。但是这种东西往往需要有一个强有力的人去推动,他会去review每个人写的代码,是否按照分层理念去写,是否符合开放封闭原则,是否遵循整洁设计,是否考虑周期等等。而要确保这些,review人总需要花比较长的时间,除了那些对软件质量要求高的企业,很少有团队这样去做,包括现在国内的一线大厂,都不舍得这样去做,“我花钱来就是请你看别人代码的吗?”对他们来说,快速写出功能上线,才是最重要的,至于代码质量早被抛诸脑后,等到出了事,就“杀一个程序员祭天”。
在这样的环境下,把前端应用当作软件,追求其稳定性健壮性,就非常不合群,因为稳定健壮的软件,会淡化开发者本身的成就,只会把最终的成就摊平到日积月累的时间长河中去,除非是某个强有力的tech leader从古老的开始就把它当孩子般培养,否则没有人愿意做一些短期内看上去根本无法凸显自己价值的事,而且更可悲的是,软件健壮稳定了,用户本身根本没有任何感知,而没有感知就会直接忽略你的劳动成果,相反,那种到处漏洞,你天天救火的,在甲方看来,还觉得你态度好,能力也够。
所以,我已经在考虑避开聊建模分层这个话题了,我想接下来我会去研究一个新的领域,这个领域得有一定的需求,同时又有深度。
-
整体上前端处于鄙视链底层也是有从业人员自身的原因。#1221 断崖上的风 2022-09-08 09:58
-
太赞同你说的这些了,然后我又想起了这篇文章:http://www.lowcoder.cn/best-practice/detail?fid=BZxGdCrUX2。不知道你怎么看待目前的应用技术发展,比如近几年涌现的微前端,低码和跨端技术。#1222 戡玉 2022-09-08 11:09
-
这块确实全在细节里,不容易被察觉。 但一个需要天天救火的项目和一个平稳运行的项目,甲方应该也是能看出差别的。#1225 1188 2022-09-16 18:29
typescript class static静态方法内this类型约束
typescript中只能对class成员进行约束,无法对static方法进行约束,我们通过一些特殊方法来实现。
/** * 用于得到某个class的构造函数,例如: * class Some {} * Constructor<Some> -> Some类型的构造函数,也就是class Some本身 * 用处: * class Some { * static fn<T>(this: Constructor<T>): void; // -> this: Constructor<T> 规定了该静态方法内的this类型,由于类型推导,此处的this被推导为Some本身 * } */ export type Constructor<T> = new (...args: any[]) => T;
通过Constructor这个util,我们可以获得一个class的type,也就是class构造函数本身。所以,当我们在静态方法中进行this约束,或返回this时,可以这样:
class Some {
static create<T>(this: Constructor<T>): T & { a: number } {
// @ts-ignore extends 要求特定结构
return class extends this {
a: number = 1;
}
}
}
通过这样对static方法进行签名,就可以在使用时很好的约束类型。
Javascript如何区分function和class?
JS里面,普通的function也可以通过new进行实例化,成为一个对象。而ES6引入的class是个阉割版本,导致class缺失了作为class的特征,class可以理解为封装的function。所以,在JS里面区分一个值是function还是class是比较麻烦的,目前TC39已经在考虑加入[[FunctionKind]]来进行区分。目前,我们可以通过一些办法来区分,如果是在纯浏览器环境下,我们有如下的一些办法:
- 通过字符串
把function或class与空字符串连接,得到字符串就可以看出来。
- 通过prototype的discriptor
对于class A而言Object.getOwnPropertyDescriptor(A, 'prototype')的writable为false,而对于function a而言,Object.getOwnPropertyDescriptor(a, 'prototype')的writable为true。
- 通过arguments
对于class A而言,A.arguments会报错,而对于function a而言,a.arguments为null。
这些方法都抵挡不住现在很多编译工具会把class编译为ES5的function,这导致这些特性都失效,所以能不能用还要看你项目里面的编译是怎么做的。
浏览器JS拉取远端图片上传到自己的服务器
我们经常遇到这样的情况,我们填写某个表单的时候,需要上传一张图片,但是我们的业务会有这样一个逻辑,我们这个表单会默认拉取一些数据进行填充,用户在这些数据基础上进行修改,这些拉取的数据,有的时候会使用外部数据里面的图片,此时,我们需要把外部数据里面的图片保存到我们自己的服务器。这种情况有两种解决方案,一种是纯后端做,一种是前端来做。我分享一种前端来做的方案。
其实拉取数据进行填充的本质,就是不破坏我们原有的表单交互流程,前后端保持高度一致,避免多态。一般的交互是提交表单的时候提交上传图片,把上传后得到的文件 id 同表单数据一起保存。这个过程是比较顺畅的,前后端理解起来非常简单。但是,如果这里强行改变这个逻辑,为了兼容多态,后端支持传 url 字符串,实际上,这也会给前端带来比较大的处理量。因此,我们的目标是,利用图片 url 转化为本地的 File 再提交到后端,走一个简单的流程,只不过在拉取数据这一步做一个处理。
那么,怎么把外部图片转化为一个浏览器里面的 File 呢?
function getImageFile(src) { return new Promise((resolve, reject) => { const img = document.createElement('img'); img.style.position = 'fixed'; img.style.top = '-10000px'; img.crossOrigin = 'anonymous'; img.src = src; img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const context = canvas.getContext('2d'); context.drawImage(img, 0, 0); canvas.toBlob((blob) => { // @ts-ignore // eslint-disable-next-line no-param-reassign blob.lastModifiedDate = new Date(); const name = src.split('/').pop(); // @ts-ignore // eslint-disable-next-line no-param-reassign blob.name = name; const file = new File([blob], name, { type: blob.type }); resolve(file); }); document.body.removeChild(img); }; img.onerror = (e) => { document.body.removeChild(img); reject(e); }; document.body.appendChild(img); }); }
这里用到了以下知识点:
- 由于是跨域的图片,所以不能直接通过 fetch 来得到 blob,于是我们利用 canvas 来加载远程图片,再利用 canvas 的接口来转化数据类型
- canvas 要能够 draw 远程图片,必须加上 crossOrigin = anonymous
- 通过 toBlob 之后得到 blob,再使用 new File([blob]) 就可以把 blob 转化为一个本地的文件,用在提交中
- 最后需要注意,提交文件需要使用 content-type: multipart/form-data 进行提交
通过这个处理,我们就可以在浏览器里面把外部图片转化为内部图片进行使用,这样就节省了后端去做类似回源CDN之类的建设。
nginx获取顶层目录,重写至顶层目录下的index.html
最近再部署测试环境时,考虑到我们的特殊需求,希望按模块进行发布测试,每个模块被放在一个文件夹中进行发布,所以不得不进行重写,来让请求fallback到目录下的index.html. 例如当我们访问 /some-module/xxx/xxx 时,实际上fallback到 /some-module/index.html。而我们发布测试模块时增量的,所以可能过两天会发布一个 any-module 来测试,不大可能写死在 nginx 的 config 中。所以,我的想法是,在 nginx 中实现一种配置,可以实现这种能力。具体实现如下:
location / {
try_files $uri $uri/ index.html @rewrite_uri;
}
location @rewite_uri {
if ($request_uri ~ /([a-z0-9_-:]+)/) {
set $tmp /$1/index.html;
rewrite ^ $tmp break;
}
}
使用 try_files 来在 nginx 中实现 fallback 的能力。增加了一个 @rewrite_uri 的规则,这个规则里面,对 $request_uri 进行匹配,通过正则把顶层目录取出来,在用 rewrite 进行规定。
通过这种方式,我们不仅可以去除顶层目录,而且可以实现我们想要的重写效果。
Typescript从联合类型转化到交叉合并后的对象类型
我们有如下一个联合类型:
type union = { a: string } | { b: number } | { c: boolean }
如何得到最终的类型:
type obj = { a: string b: number c: boolean }
如果是用js来写,不要太简单,但是typescript没有对union的遍历能力,所以只能走其他办法,经过探索后最终得到如下解:
type GetUnionKeys<Unoin> = Unoin extends any ? { [key in keyof Unoin]: key; } extends { [key in keyof Unoin]: infer K; } ? K : never : never; type UnionToInterByKeys<Union, Keys extends string | number | symbol> = { [key in Keys]: Union extends any ? { [k in keyof Union]: k extends key ? Union[k] : never; } extends { [k in keyof Union]: infer P; } ? P : never : never; }; type UnionToInter<Unoin> = UnionToInterByKeys<Unoin, GetUnionKeys<Unoin>>;
在使用时如下:
type obj = UnionToInter<union>
这样就可以实现我们的目的。我写了两个原子范型,GetUnionKeys用于获取联合类型里面的全部key,UnionToInterByKeys是关键,用于基于前面获得的keys再得到最后的对象。
不过这个方法存在一定的风险,假如联合类型中存在相同的属性,那么可能存在不确定性,比如:
type obj = UnionToInter<{ a: string, b: number } | { b: boolean }>
我们最终就会得到:
type obj = { a: string b: number | boolean }
但是我们再开发中,常常会让后面一个覆盖前面的,我们期望得到的是:
type obj = { a: string b: boolean }
我之前在读一些博客的时候,有同学提到可以通过冲载机制得到联合类型中的最后一个元素,但是我没有找到这篇文章,如果你看到,请在下方留言给我。
-
深奥, 我只会合并两个, 这种批量的就完全不会了.
type UnionZ<A> = {
[k in (keyof A | keyof B)]: k extends keyof B ? B[k] : k extends keyof A ? A[k] : never;
};#1195 1188 2022-05-17 11:30 -
赞#1209 千劫 2022-07-21 15:11
Nodejs检查端口是否占用,如果被占用使用一个随机端口
在日常开发中,我们常常要自己启动一个server来进行本地调试,但是如果写死端口,就会导致端口冲突,怎么解决呢?
/* eslint-disable @typescript-eslint/no-require-imports */ const net = require('net'); function checkPortUsable(port) { return new Promise((resolve, reject) => { const server = net.createConnection({ port }); server.on('connect', () => { server.end(); reject(`Port ${port} is not available!`); }); server.on('error', () => { resolve(port); }); }); } function randomNumByRange(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function findUsablePort(port, minPort = 10000, maxPort = 65536) { const retry = () => { const port = randomNumByRange(minPort, maxPort); return findUsablePort(port, minPort, maxPort); }; return checkPortUsable(port) .then(() => port) .catch(retry); } module.exports = { findUsablePort, checkPortUsable, };
具体使用的时候,调用findUsablePort即可:
findUsablePort(4000).then((port) => app.listen(port))
如果4000可用,就会直接用4000,否则就会使用一个随机端口。
-
net.createConnection({ port })这种模式是建立客户端尝试连接该端口, 但是如果该端口已经被使用,但防火墙做了限制,应该也会触发连接错误; 会造成误判;
用创建服务监听的方式应该更完善一些.#1220 躁动de气球 2022-08-31 17:33
用外观模式实现跨端开发
外观模式即对外提供统一接口,底层实现随意。对于使用者而言,使用基于外观模式的库时,在任何情况下,想要调用某个功能时,都使用相同的api。在日常中,常见的外观模式就是不同浏览器,都基于HTML标准提供相同接口,但是实际上浏览器内部怎么实现这些接口的,完全不一样(当然,由于不一样的实现,在实际运行时也会导致运行效果差异)。
那么在跨端开发时,怎么做呢?我们要创建一个H5页面和一个RN界面,我们使用相同的接口调用某库,然后希望被调用的接口在web和原生上都能用。例如:
import SomeLib from 'some-lib' const pdf = SomeLib.print('xxx')
对于some-lib这个库而言,它提供 print 接口。在web和原生平台上都这样去使用,使用层面没有差异。但是,我们知道,web上和原生上调用系统的打印功能是不一样的,所以我们又要分开去实现不同平台上的具体能力。设计上怎么做呢?如下:
SomeLib.implement((text) => { console.log(text) })
这是在web上的实现,在原生上的实现可能需要借助原生提供的接口来做。对于some-lib这个库而言,它本身实际上没有实现打印这个过程,这个过程要开发者自己去实现。但是这个库却提供一系列用于打印的功能。
通过这种设计,我们就可以在应用的开发上做到比较方便的跨端开发。
-
和react有点像呢#1185 1188 2022-04-28 09:55
JS如何判断用户是否点击浏览器“退回”按钮返回上一个界面?
通过window的popstate事件可以监听history的变化,但是,“popstate会在浏览器前进后退操作、history.go/back/forward调用、hashchange的时候触发”,它是一个复合事件,你根本判断不了到底是哪种情况引起的popstate。更难理解的是,我不知道为什么要把forward也设计为popstate,pop这个词的意思不就是从stack顶把最上面的一个从栈中移除么?forward明显是push的行为,怎么也放到popstate里面。这个事件明显是有设计缺陷的。回到题目,我们怎么去判断用户是点击了“退回”按钮?
我们要用到一些取巧的办法。具体我总结为3步:
- 充实history stack,以提供更多信息让我们可以在用户刷新浏览器的情况下,仍然获得上下页信息关联
- 为history创建一个私有的记录值,用以区分history当前的state和上一个state(我们在事件回调中只能拿到当前state)
- 通过第一步和第二步铺垫的内容,在回调函数中进行判断,从而判断是否是用户点击了“退回”按钮
接下来我们进行实施。在此之前,我们需要了解一些简单的知识。history是浏览器用户记录用户浏览器历史的全局对象,既然是“浏览历史”,那么就是一组单一数据的列表(有顺序),这个所谓单一数据,就是其内部的state概念,一个state表示用户在浏览一个界面(对应一个url)时留下的痕迹,不过,这个state必须由开发者定义,如果开发者不定义,那么state就是null,而且为null的时候,就没哟意义。注意,浏览器不会主动帮你记录当前的url,虽然它自己记录在了浏览器内部,你可以通过浏览器的“浏览历史”功能查看,但是,你无法在代码层面直接读取这些历史记录,你只能读取history当前的state,即通过 window.history.state
来读取,当然,你也可以通过 window.location
来读取当前的url信息。“只能读取当前的”,也就意味着这是一种栈结构的数据管理,而且,这个栈在用户刷新当前浏览器tab时,仍然是维持的。
基于这一特性,我们可以自己在state栈中,构建一个链表结构,从而可以读取整个栈内的state链。具体怎么做呢?
我们知道history有pushState和replaceState两个接口,对于SPA应用而言,整个应用中只会使用pushState和replaceState两个接口进行url的跳转(还有一种是在a标签href中使用#触发,例如 <a href="#/base/xxx">
这种也可以做到无刷新的界面跳转,但是由于它走另外一个体系,也就是hashchange的体系,是比较老的技术,现在大部分框架都是基于state的体系来做,因此,我们本文不考虑hashchange这种方案),因此,我们可以对这两个接口进行改造,从而在跳转时,对state进行信息充实。
const originalPushState = history.pushState.bind(history); // 要求在调用pushState时state必须是一个对象 history.pushState = function(state, title, url) { const { state: currentState } = history; // 获取未跳转之前的state,也就是当前的state const nextState = state || {}; nextState.prevState = currentState; originalPushState(nextState); };
通过这一改造,我们重写了history.pushState,这样我们就让每一个被push到state栈中的state拥有了prevState属性,通过该属性就可以形成一条反向链表,用以追踪state之间的关系。
接下来,我们创建一个私有的变量,用来记录history.state发生变化前的state.
let latestState = history.state; window.addEventListener('popstate', () => { const prevState = latestState; const { state } = history; latestState = state; .... })
在每一次popstate被触发时,我们去修改latestState,这样,我们就可以记录在state发生变化前的state是哪一个。
最后,我们通过latestState和当前的state进行对比,来猜测用户是否点击了“退回”按钮。
window.addEventListener('popstate', () => { const currentState = latestState; const { state: nextState } = history; latestState = nextState; if (currentState?.prev === nextState) { // 用户点击了“退回”按钮 } })
当变化后的state正好是变化前state.prev时,我们就认为用户点击了“退回”按钮。当然,这里有一个点需要提前预设,因为在不同的SPA框架中,触发popstate的可能包含在代码中调用history.back()和history.go(-1)的情况,所以,在代码层面,需要继续去区分到底是程序里调用.back还是用户点击“退回”按钮。区分方法也很简单,你可以重写back和go方法,在里面做一个标记,从而在判断时增加对该标记的判断。