关于 kily zhou

博客名:臨池不輟。爱思考哲学的前端。

RSS 地址: https://keelii.com/atom.xml

请复制 RSS 到你的阅读器,或快速订阅到 :

kily zhou RSS 预览

我做程序员这十年

1970-01-01 08:00:00

今天是个特殊的日子,十年前的这个时候我根本无法想像我可以在一家公司呆十年,坚持做一样工作十年之久。于是我就想要写点儿东西来回顾一下这十年的发生在我身上的事情。工作、学习、编程、生活。

起因

我接触电脑的时间比较早,大约在小学五、六年级的时候就有了微机课。似乎是邓小平爷爷的一句「计算机要从娃娃抓起」的原因,学校采购了一批微机。每周都有一节课,大家都很期待。课堂上老师会教我们打字,在漆黑的屏幕上敲击 DOS 命令。

那个时候只感觉电脑很神奇,似乎就是电视机和游戏机的合体。

直到后来上了初中,有了互联网,有了网上聊天,有了局域网对战游戏。似乎电脑的用处又多了很多。

我人生中的第一台电脑是上高中时我三伯从深圳带回来的。上面装的是 Windows 98,后来有了 XP。但是我发现那台电脑太旧了硬件根本不支持安装 XP。这让我很失望,因为当时的 XP 看起来非常赏心悦目,比起 98 那种棱角分明的黑白灰风格漂亮多了。

直到现在我还记得,当时我专门进了一趟城,买了两张 3.5 英寸软盘。打算去网吧下载几首歌曲用软盘复制回家里的电脑上面。因为那时候家里面的电脑还没联网。因为3.5寸软盘容量只有 1.44MB,一首 mp3 格式的歌至少 3M 起,完全放不下。后来专门下载了另外一种音乐文件格式叫 wma,比 mp3 有更高的压缩率。一张软盘可以复制差不多两首歌曲。虽然那时候已经有能放 mp3 的随身听了但是折腾这个还挺有意思的。

接着就是玩 QQ 空间,那时候比较流行空间装扮。网上有很多看不懂的代码,复制到 QQ 空间的自定义模块里面去就会有很多神奇的效果。Flash 动画,一首动听的音乐,一张漂亮的图片。每当我去看别人的空间时总感觉:人家的空间怎么装扮的这么漂亮。

高中毕业后我就从城里买了一本年度版本的《电脑报》,用来排解那段无聊的夏日。当然我看完后,其实真正懂的只有一半不到,很多专业的用词,根本不知道是什么意思。当时就觉得会安装操作系统就已经非常利害了。

也就是由于这本电脑报的启蒙,让我在放假期间报志愿时选择了计算机软件这个领域。我几乎是很轻易的就做出了这个选择,选什么专业这个问题上家人们并没有强行给我建议。我当时只知道一点:世界首富比尔盖茨是干这个的,所以我觉得我要是做这个应该也不会太差。

上了大学就开始学软件专业相关的知识,实际上真正学起来的时候也是很枯燥,理论上的东西对于我来说总是让我感到望而生畏。但是好在我因为我是这个专业的,所以还保持了这个专业的一些基本操守。比如:我喜欢写博客、搭网站、倒腾服务器什么的。写博客是因为当时也流行这个,当年韩寒和徐静蕾博客就很出名。我觉得自己也可以写写,但是毕竟咱是搞计算机的,怎么着也得弄个专业一点的,完全是自己设计的网站那种。而不是用新浪博客这种托管的博客站点。最重要的一点是:托管的博客站点他们提供的控制博客主题样式的功能限制太大,而且也没法自定义域名。这就让我觉得没意思,因为我就想做一点和别人不一样的事情。

然后专业课上也学习了网页制作相关的技术。用 Dreamweaver 拖图片到表格布局中去,拼成一个网页,这是当时书上教的。但是我上网上查过之后发现这种模式已经过时了,当时流行一个网站重构的概念,使用 CSS 来进行页面设计,会让你的页面更加炫酷。

当时我就知道有一个网站叫做 CSS Zen Garden,它的主题思想就是提供一套 HTML 代码,然后只允许你使用 CSS 对页面的元素进行布局、设计。上面有很多非常棒的设计作品,只是你很难想像这是基于同一个 HTML 设计出来的。这也是 CSS 的魅力所在,限制你的并不是技术,而是你的创意和想法。

我大概就是这样进入到前端这个领域的。

经过

大学还没毕业,就赶上了当时互联网的一股浪潮 —— 电子商务,其实就是网上卖东西。那些年几乎每年都有一样新的互联网概念出世:论坛、聊天室、博客、微博、团购、电商。似乎是中国互联网百花齐放的时代。哦,对了。那时候社交网络是 MySpace,Twitter 还没流行起来。不一样的是当时这些网站都可以访问。

通过写博客、逛论坛。我被一些创业的老板盯上了,还没毕业就联系我想让我去北京上班。我当时想的是先毕业再说。只是当时其它同学好像都很着急找工作了,但是我一点都不急,最后一个学期了我还常常自己玩自己的游戏,自己学自己的东西。好像在我的意识里从来就没有找不到工作这种设定。后来我才知道当时一些同学早早的去找工作,在西安一个月七八百块钱就不错了,还不管吃住的那种。

后来毕业后我就来到了北京,这个让人充满向往的城市。先后呆了两家公司后,来到了现在的公司。基本上我换工作的原因只有一个,就是我做的事情限制了我的成长。我感觉学不到什么新东西了我就会离职。

刚开始都是只写 HTML/CSS,小公司一般会这个就够了。但是稍大一点的公司,就需要我会写 JavaScript,那时候才感觉至少水平到了 JS 这一层才有了编程的概念。会 JS 就能去大公司、正规公司,也能学到很多未知的技术。

后来在公司一直做了大概有4年的前端工程师,那段时间里是我写代码频率最高的一段时间,因为业务需求多,前端要做的事情也很多。那时候流行模块化、组件化、工具自动化这些概念。慢慢的 Node.JS 也出现了,前端有了要开始要跨越和后端之间的那条界线的趋势。整个行业中前端工程师的整体素质也有了很大的提升。再后来你会发现很多做后端的同学转做前端,反而做的更好了。因为大家认识到了前端的重要性,前端不再是一些表层的东西。前端变成了一种和用户沟通的形式。

此时我也发现了自身的一些瓶颈,很多东西无法深入下去。有的概念几乎全是空白,于是我就去看一些更专业领域的书箱资料。学习了 Python, 了解了 Ruby,补上了操作系统相关知识点。后端可以说也入门了,此时我只需要一个实践的机会。

也是机缘巧合,由于公司变动调整,我转做了一年的 Java 工程师。这让我对于无论是编程语言层面,还是系统框架层面都有了新的认知,把我之前学习的零散的东西都建立成了一种体系。并且当我维护过十万行级别的代码的时候,我才对技术有了更加深刻的认识,对技术才产生了敬畏之心。

我在考虑问题的时候不再只看到我自己的那一面。而是技术上从系统层面看,功能上从产品层面看,管理上从项目的层面多角度的去理解一个软件产品生命周期。因此,我似乎具有了一种跨跃式的思维模式,从技术层面看清产品的本质,从产品层面理清楚技术的突破点。

直到现在,虽然我冠有前端工程的虚名。但事实上这并没有限制我做的事情。因为我从来不给我自己打标签。相对于这些名义上的东西来讲我更关心我正在做的具体的事情,是我做的这些事情定义了我是一个怎么样的人,而不是那些标签。

结果

现在回看这十年间的我。北漂、地下室、租房、买房、成家、养育,这些关键词都成为了我经历中的一部分。我从来都没有想像过我能在北京这座城市实现这一切。

从感情上讲我是很讨厌北京这个城市的,因为他没有生活,只有拼搏。但是从理性上讲,我现在拥有的几乎所有世俗意义上的成就都是北京这个城市给我的。因为她公平,所以我才有机会。

我在公司这十年里面,几乎每年都会晋升。我和公司的关系已经不是简单的雇佣和被雇佣关系。而是相互成就、相互欣赏。

虽然不知道未来的路还能走多长,但是有句话说得好:

但行好事,莫问前程。

顿悟

许多人都会因为自己工作或者职位的原因而给自己画个圈圈。我是一个程序员,程序员就是怎样、怎样的。

我在刚开始的时候,出于一种自恋式的骄傲我自己也这么认为。我觉得程序员是不善言辞的、有思想的、专注的一个群体。当我尝试用一些美好的词语去描绘他们的时候,我发现这并不完整,之于我自己更是如此。

但事实上程序员也是普通人。

他们有细腻、感性的一面
他们也有果敢、理性的一面
他们有或专业或普通的能力
他们有或高雅或低俗的需求
他们豪放、他们矜持
他们独一无二

不为别的,只因为他们是芸芸众生中所有普通人中的某一个完整的人而已。

如果说非要我总结几句身为程序员的行事格律,那我觉得应该是以下几句话:

  1. 关注问题的本质,但不只关注本质
  2. 给出方案前务必要讲清楚问题是什么
  3. 不要给自己打标签,别人行,你不行
  4. 不要使用一样你不了解的技术
  5. 从高层理解设计,从底层研究原理
  6. 问题和结论不在一个维度上时没有讨论的意义
  7. 分清楚什么是事实认知什么是情感认知
  8. 生活不是诗,但你是一个诗人

ten-years

从《如梦令·昨夜雨疏风骤》读出人生三重境界

1970-01-01 08:00:00

昨夜雨疏风骤,浓睡不消残酒。试问卷帘人,却道海棠依旧。知否,知否?应是绿肥红瘦。
—— 李清照〔宋〕

看山原是山 —— 一切景语皆情语

开头两句「昨夜雨疏风骤,浓睡不消残酒」是交代场景。昨天晚上下着稀疏小雨,伴着大风。睡了一觉醉意仍然没有消退。浓睡不消残酒,此处用「浓」字形容沉睡,浓本来是形容液体的,「浓睡」,「残酒」等字眼这就已经奠定了一大早她本身内心还未化解开的某种忧怨。她这才意识到院子里面的海棠花经历了风雨交加的夜晚,不知道情况如何了。

于是有了对话「试问卷帘人,却道海棠依旧」。这两句中的「试」、「却」被认为是最为精妙的内心情景描写。

「试」是因为李清照还不确定海棠花全部凋谢了,她内心还暂存了一点希望,哪怕只是一点点,就是这一点点支撑着她一大早醒来就急切地想知道院子里的情况。

「却」是因为「海棠依旧」肯定是不可能的,李清照读了很多书,经历了多少春夏秋冬,她不可能不知道春去夏来之时,下雨会带走海棠花。

虽然李清照不情愿承认海棠花凋零的事实,但是理智让她发出了对卷帘人的嗔叹:

「知否,知否?应是绿肥红瘦」。知道吗,知道吗?这个时节,现在的院子里应该是绿叶茂盛,红花凋零的时候。「绿肥红瘦」是全词最为绝妙的四个字,绿代表着夏天、树叶,红代表着春天、花开。「绿肥红瘦」则表达的是春去夏来海棠花凋零,青春、时光即逝的伤感。

看山不是山 —— 最懂她的卷帘人

通常多数人认为在这首词中,卷帘人只是不走心的说了一句「海棠依旧」。但是恰恰相反,我觉得在这首词里面「卷帘人」才是点睛之笔。

卷帘人的回答「海棠依旧」充满了智慧,卷帘人有着极高的情商,或许只有她懂李清照。

我们从「试问卷帘人」这句开始分析,此刻李清照很想知道风雨过后院子里面的情况。

卷帘人回答「海棠依旧」有两种可能:

其一,李清照当时问的就是「院子里面的海棠花怎么样了?」,卷帘人说:「海棠依旧」 其二,李清照的院子里面几乎全是海棠花,她问院子里的情况,卷帘人自然知道她问的是海棠花,于是说:「海棠依旧」

这两种可能,无论是哪种都至少说明了李清照非常关心海棠花如何了,她关心海棠花是因为她不想看见海棠花凋零而因此伤感,但是理智又告诉他时间(海棠花凋零的)到了,再美的东西都熬不过时间。只是她心里面还有那么一点点念想,或许还有那么几支还未凋零呢?

可以想象,此时如果卷帘人告诉他院子里的真实情景,那该有多残酷。难道卷帘人要告诉她:你那些心爱的海棠花都被昨天夜里的大雨打的凋零不堪、破败不堪、全部死掉了。那李清照岂不要哭死?

所以说卷帘人很懂李清照为何尽兴、为何伤感。卷帘人天天和李清照在一起,她的一个表情、一个神态、一颦一笑一忧愁都在卷帘人的眼里...

如果我是那个卷帘人,我是段不忍心告诉李清照真实情况的,那将是无比残忍、无比无情的人才说的出来的。

试问没有卷帘人「知否」何在?

试问没有卷帘人「绿肥红瘦」又将何在?

看山还是山 —— 何谓海棠依旧

事实上如果没有卷帘人的存在,这首词似乎也能讲得通。我们理解的「试」和「却」两个字之间存在一种矛盾感。「试」表示不确定,心存的期许。「却」表示确定的转折对比,事实的无奈。

此语境更像是一位说梦的痴人被「海棠依旧」点醒了一般,又好像是一种痴人说梦般的暗自言语。

这首词音律是那么的完美,没有一个多余的字。节奏时急时缓、时快时慢、时抑时扬、时而押韵、时而叙述,如绵绵细流一般任意思绪流淌。如此只可能是一个人的自言自语,才能一气呵成。

所以其实根本没有卷帘人,只是李清照自言自语罢了。因为孤独的人都喜欢旁若有人般的暗自言语~

那么「海棠依旧」究竟意味着什么?

你难道不觉得「海棠依旧」这四个字充满了智慧吗?智慧到根本不像是由卷帘人口中说出来的。要么是李清照自己杜撰的,要么就是上面分析的那种可能——卷帘人有着超人的智慧。

或许李清照只想到了当下的绿肥红瘦,伤感于青春、时光的即逝。还在伤感困惑的她被卷帘人口中的「海棠依旧」所点化。

卷帘人淡淡地说道:「明年海棠花依旧会开,你又何必如此的感伤。春去春会回来,又有谁人绕过了时光?」

「海棠依旧」的真正意思不就是所谓的天之道吗?客观规律,不以人的意志为转移的道。

不管你信不信,海棠花每年都会开,不会因为你的怜惜海棠花就晚几日凋谢,也不会因为你的期许海棠花就提前开放。

此时,「海棠依旧」到底是出自谁口已经不重要了。

因为明天太阳照常升起,因为明年海棠依旧。



悟道休言天命,
修行勿取真经。
一悲一喜一枯荣,
哪个前生注定?

袈裟本无清净,
红尘不染性空。
幽幽古刹千年钟,
都是痴人说梦。

所谓的设计系统

1970-01-01 08:00:00

是的,我今天就想批判一下那些披着「设计(系统/语言)」外衣的研发工程师。

设计系统(Design system)这个概念应该是从国外最先有的。它的定义是:

A Design System is a set of interconnected patterns and shared practices coherently organized
设计系统是被统一组织起来的一系列紧密关联的模式和可重用的实践。

国内的组件库只有 Antd 在推出的时候声称自己是 一个 UI 设计语言,虽然当时我还不知道什么叫做设计语言,什么是设计系统。但是从工程师的角度我知道它就是 一套组件库

一直到近两年,越来越多的前端团队以设计系统为排面推出自己的组件库的时候,我就觉得有些不对劲儿了。

哪里不对劲儿了?我在想我们实际上做的事情不就是设计了一套组件库吗,为什么要把设计放在前面。这似乎传达给我们一种信号:

我们应该是设计先行?我们应该优先做设计角度取舍。
相反的,工程层面实现那就应该向设计方向妥协?

我想并不是这样的,或者说不应该是这样的。

设计 永远是最上层的,它们关注的用户的外在、外观感受,它是感性的、多变的,没有唯一标准的。你很难想象用一套设计系统满足所有人的需求对吧?因为我喜欢蓝色,你喜欢红色这是不需要解释的。

工程 永远是最底层的,它关注事物内在的东西、自身属性,它是理性的、不变的,有迹可循的。所以工程层面追求的是一致、复用和效率。没人喜欢一个相同的组件在不同的实现上有不一样的 API。

在「前端工程师」这个职位名字中,「前端」是个形容词,「工程师」才是名词。

我们得先是工程师再是前端对吧。当我们不由自主地和设计靠拢时,思维模式也受到了影响,似乎只有设计思维才会关注到那些形容词。

当我们在设计一套组件库的时候会遇到很多不一致的情况,在我的经验里面:

来自与设计和实现的不一致要多于纯粹实现层面的不一致。

当我们设计的组件库需要考虑到跨端情况的时候,我们的组件库应该有一套一致的 API、一致的命名规则。但是从设计角度去决策的时候这件事情变得非常困难。

比如说:日期选择 这个组件,移动端通常叫做 DatePicker,这个词强调的是用户的动作(pick),在 PC 端通常叫做 Calendar,这个词强调的是组件本身的特征。

但是我们在工程实现层面真他妈不需要这种差别,它就是一个日期选择组件而已。

还有:按钮 组件的类型属性,有的是表形的:default/info/warning/error,有的是表义的:primary/secondary/danger

再一次,我们在工程实现层面真他妈不需要这种差别。它就是一个简单的按钮而已。

造组件库的人没有想清楚这件事情,用组件库的人却得承受这种不一致。那什么组件库的设计者们没想清楚这件事情?

因为他们的思维也被设计系统带偏了。一味的追求设计上的形式化的一致,却忽略了工程上逻辑的一致。

当这股邪风吹来,没人会在意组件库的工程化设计,没人在意它好不好用,每个人都想复制粘贴快速实现一套组件库,然后再披上设计系统的外衣,为自己的似锦前程添砖加瓦...

Fabric.js 原理与源码解析

1970-01-01 08:00:00

Fabric.js 简介

我们先来看看官方的定义:

Fabric.js is a framework that makes it easy to work with HTML5 canvas element. It is an interactive object model on top of canvas element. It is also an SVG-to-canvas parser.

Fabric.js 是一个可以让 HTML5 Canvas 开发变得简单的框架 。 它是一种基于 Canvas 元素的 可交互 对象模型,也是一个 SVG 到 Canvas 的解 析器(让SVG 渲染到 Canvas 上)。

Fabric.js 的代码不算多,源代码(不包括内置的三方依赖)大概 1.7 万行。最初是 在 2010 年开发的, 从源代码就可以看出来,都是很老的代码写法。没有构建工具,没有 依赖,甚至没使用 ES 6,代码中模块都是 用 IIFE 的方式包装的。

但是这个并不影响我们学习它,相反正因为它没引入太多的概念,使用起来相当方便。不需 要构建工具,直接在 一个 HTML 文件中引入库文件就可以开发了。甚至官方都提供了一个 HTML 模板代码:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <script src="https://rawgit.com/fabricjs/fabric.js/master/dist/fabric.js"></script>
  </head>
  <body>
    <canvas id="c" width="300" height="300" style="border:1px solid #ccc"></canvas>
    <script>
      (function() {
        var canvas = new fabric.Canvas('c');
      })();
    </script>
  </body>
</html>

这就够了不是吗?

使用场景

从它的官方定义可以看出来,它是一个用 Canvas 实现的对象模型。如果你需要用 HTML Canvas 来绘制一些东西,并且这些东西可以响应用户的交互,比如:拖动、变形、旋转等 操作。 那用 fabric.js 是非常合适的,因为它内部不仅实现了 Canvas 对象模型,还将一 些常用的交互操作封装好了,可以说是开箱即用。

内部集成的主要功能如下:

Canvas 开发原理

如果你之前没有过 Canvas 的相关开发经验(只有 JavaScript 网页开发经验),刚开始 入 门会觉得不好懂,不理解 Canvas 开发的逻辑。这个很正常,因为这表示你正在从传统 的 JavaScript 开发转到图形图像 GUI 图形图像、动画开发。 虽然语言都是 JavaScript 但是开发理念和用到的编程范式完全不同。

这两种开发方式各有各的优势,比如:

Canvas 开发的本质其实很简单,想像下面这种少儿画板:

少儿画板

Canvas 的渲染过程就是不断的在画板(Canvas)上面擦了画,画了擦。

动画就更简单了,只要渲染 帧率 超过人眼能识别的帧率(60fps)即可:

<canvas id="canvas" width="500" height="500" style="border:1px solid black"></canvas>
<script>
    var canvas = document.getElementById("canvas")
    var ctx = canvas.getContext('2d');
    var left = 0

    setInterval(function() {
        ctx.clearRect(0, 0, 500, 500);
        ctx.fillRect(left++, 100, 100, 100);
    }, 1000 / 60)
</script>

当然你也可以用 requestAnimationFrame,不过这不是我想说明的重点。

Fabric.js 源码解析

模块结构图

fabric.js 的模块我大概画了个图,方便理解。

Fabric.js 的模块结构

基本原理

fabric.js 在初始化的时候会将你指定的 Canvas 元素(叫做 lowerCanvas)外面包裹上一 层 div 元素, 然后内部会插入另外一个上层的 Canvas 元素(叫做 upperCanvas),这两 个 Canvas 有如下区别

内部叫法 文件路径 作用
upperCanvas src/canvas.class.js 上层画布,只处理 分组选择事件绑定
lowerCanvas src/static_canvas.class.js 真正 绘制 元素对象(Object)的画布

核心模块详解

上图中,灰色的模块对于理解 fabric.js 核心工作原理没多大作用,可以不看。其它核心 模块我按自己的理解来解释一下。

所有模块都被挂载到一个 fabric 的命名空间上面,都可以用 fabric.XXX 的形式访问。

fabric.util 工具包

工具包中一个最重要的方法是 createClass ,它可以用来创建一个类。 我们来看看这 个方法:

function createClass() {
  var parent = null,
      properties = slice.call(arguments, 0);

  if (typeof properties[0] === 'function') {
    parent = properties.shift();
  }
  function klass() {
    this.initialize.apply(this, arguments);
  }

  // 关联父子类之间的关系
  klass.superclass = parent;
  klass.subclasses = [];

  if (parent) {
    Subclass.prototype = parent.prototype;
    klass.prototype = new Subclass();
    parent.subclasses.push(klass);
  }
  // ...
}

为什么不用 ES 6 的类写法呢?主要是因为这个库写的时候 ES 6 还没出来。作者沿用了 老 式的基 于 JavaScript prototype 实现的类继承的写法, 这个方法封装了类的继承、 构造方法、 父子类之前的 关系等功能。注意 klass.superclassklass.subclasses 这两行, 后面会讲到。

添加这两个引用关系后,我们就可以在 JS 运行时动态获取类之间的关系,方便后续序列化 及反序列化操 作,这种做法类似于其它编程语言中的反射机制,可以让你在代码运行的时 候动态的构建、操作对象

initialize() 方法(构造函数)会在类被 new 出来的时候自动调用:

function klass() {
  this.initialize.apply(this, arguments);
}

fabric 通用类

fabric.Canvas

上层画布类,如上面表格所述,它并不渲染对象。它只来处理与用户交互的逻辑。 比如: 全局事件绑定、快捷键、鼠标样式、处理多(分组)选择逻辑。

我们来看看这个类初始化时具体干了些什么。

fabric.Canvas = fabric.util.createClass(fabric.StaticCanvas, {
    initialize: function (el, options) {
        options || (options = {});
        this.renderAndResetBound = this.renderAndReset.bind(this);
        this.requestRenderAllBound = this.requestRenderAll.bind(this);
        this._initStatic(el, options);
        this._initInteractive();
        this._createCacheCanvas();
    },
    // ...
})

注意:由于 createClass 中第一个参数是 StaticCanvas,所以我们可以知道 Canvas 的父类 是 StaticCanvas

从构造方法 initialize 中我们可以看出:

只有 _initInteractive_createCacheCanvas 是 Canvas 类自己的方法, renderAndResetBoundrequestRenderAllBound_initStatic 都继承自父类 StaticCanvas

这个类的使用也很简单,做为 fabric.js 程序的入口,我们只需要 new 出来即可:

// c 就是 HTML 中的 canvas 元素 id
const canvas = new fabric.Canvas("c", { /* 属性 */ })
fabric.StaticCanvas

fabric 的核心类,控制着 Canvas 的渲染操作,所有的画布对象都必须在它上面绘制出来 。我们从构造函数中开始看

fabric.StaticCanvas = fabric.util.createClass(fabric.CommonMethods, {
    initialize: function (el, options) {
        options || (options = {});
        this.renderAndResetBound = this.renderAndReset.bind(this);
        this.requestRenderAllBound = this.requestRenderAll.bind(this);
        this._initStatic(el, options);
    },
})

注意:StaticCanvas 不仅继承了 fabric.CommonMethods 中的所有方法,还继承了 fabric.Observablefabric.Collection,而且它的实现方式很 JavaScript,在 StaticCanvas.js 最下面一段:

extend(fabric.StaticCanvas.prototype, fabric.Observable);
extend(fabric.StaticCanvas.prototype, fabric.Collection);
fabric.js 的画布渲染原理
requestRenderAll() 方法

从下面的代码可以看出来,这个方法的主要任务就是不断调用 renderAndResetBound 方 法 renderAndReset 方法会最终调用 renderCanvas 来实现绘制。

requestRenderAll: function () {
  if (!this.isRendering) {
    this.isRendering = fabric.util.requestAnimFrame(this.renderAndResetBound);
  }
  return this;
}
renderCanvas() 方法

renderCanvas 方法中代码比较多:

renderCanvas: function(ctx, objects) {
    var v = this.viewportTransform, path = this.clipPath;
    this.cancelRequestedRender();
    this.calcViewportBoundaries();
    this.clearContext(ctx);
    fabric.util.setImageSmoothing(ctx, this.imageSmoothingEnabled);
    this.fire('before:render', {ctx: ctx,});
    this._renderBackground(ctx);

    ctx.save();
    //apply viewport transform once for all rendering process
    ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]);
    this._renderObjects(ctx, objects);
    ctx.restore();
    if (!this.controlsAboveOverlay && this.interactive) {
        this.drawControls(ctx);
    }
    if (path) {
        path.canvas = this;
        // needed to setup a couple of variables
        path.shouldCache();
        path._transformDone = true;
        path.renderCache({forClipping: true});
        this.drawClipPathOnCanvas(ctx);
    }
    this._renderOverlay(ctx);
    if (this.controlsAboveOverlay && this.interactive) {
        this.drawControls(ctx);
    }
    this.fire('after:render', {ctx: ctx,});
}

我们删掉一些不重要的,精简一下,其实最主要的代码就两行:

renderCanvas: function(ctx, objects) {
    this.clearContext(ctx);
    this._renderObjects(ctx, objects);
}

clearContext 里面会调用 canvas 上下文的 clearRect 方法来清空画布:

ctx.clearRect(0, 0, this.width, this.height)

_renderObjects 就是遍历所有的 objects 调用它们的 render() 方法,把自己绘制 到画布上去:

for (i = 0, len = objects.length; i < len; ++i) {
    objects[i] && objects[i].render(ctx);
}

现在你是不是明白了文章最开始那段 setInterval 实现的 Canvas 动画原理了?

fabric 形状类

fabric.Object 对象根类型

虽然我们已经明白了 canvas 的绘制原理,但是一个对象(2d元素)到底是怎么绘制到 canvas 上去的,它们的移动怎么实现的?具体细节我们还不是很清楚。 这就要从 fabric.Object 根类型看起了。

由于 fabric 中的 2d 元素都是以面向对象的形式实现的,所以我画了一张内部类之间的继 承关系,可以清楚的看出它们之间的层次结构

fabric-objects-hierarchy

不像传统的 UML 类图那样,这个图看起来还稍有点乱,因为 fabric.js 内部实现的是多重 继承,或者说类似于 mixin 的一种混入模式实现的继承。

从图中我们可以得出以下几点:

Object 类常用属性

下面的注释中,边角控制器 是 fabric.js 内部集成的用户与对象交互的一个手柄,当 某个对象处于激活状态的时候,手柄会展示出来。如下图所示:

fabric.js-conner

常用属性解释:

// 对象的类型(矩形,圆,路径等),此属性被设计为只读,不能被修改。修改后 fabric 的一些部分将不能正常使用。
type:                     'object',
// 对象变形的水平中心点的位置(左,右,中间)
// 查看 http://jsfiddle.net/1ow02gea/244/ originX/originY 在分组中的使用案例
originX:                  'left',
// 对象变形的垂直中心点的位置(上,下,中间)
// 查看 http://jsfiddle.net/1ow02gea/244/ originX/originY 在分组中的使用案例
originY:                  'top',
// 对象的顶部位置,默认**相对于**对象的上边沿,你可以通过设置 originY={top/center/bottom} 改变它的参数参考位置
top:                      0,
// 对象的左侧位置,默认**相对于**对象的左边沿,你可以通过设置 originX={top/center/bottom} 改变它的参数参考位置
left:                     0,
// 对象的宽度
width:                    0,
// 对象的高度
height:                   0,
// 对象水平缩放比例(倍数:1.5)
scaleX:                   1,
// 对象水平缩放比例(倍数:1.5)
scaleY:                   1,
// 是否水平翻转渲染
flipX:                    false,
// 是否垂直翻转渲染
flipY:                    false,
// 透明度
opacity:                  1,
// 对象旋转角度(度数)
angle:                    0,
// 对象水平倾斜角度(度数)
skewX:                    0,
// 对象垂直倾斜角度(度数)
skewY:                    0,
// 对象的边角控制器大小(像素)
cornerSize:               13,
// 当检测到 touch 交互时对象的边角控制器大小
touchCornerSize:               24,
// 对象边角控制器是否透明(不填充颜色),默认只保留边框、线条
transparentCorners:       true,
// 鼠标 hover 到对象上时鼠标形状
hoverCursor:              null,
// 鼠标拖动对象时鼠标形状
moveCursor:               null,
// 对象本身与边角控制器之间的间距(像素)
padding:                  0,
// 对象处于活动状态下边角控制器**包裹对象的边框**颜色
borderColor:              'rgb(178,204,255)',
// 指定边角控制器**包裹对象的边框**虚线边框的模式元组(hasBorder 必须为 true)
// 第一个元素为实线,第二个为空白
borderDashArray:          null,
// 对象处于活动状态下边角控制器颜色
cornerColor:              'rgb(178,204,255)',
// 对象处于活动状态且 transparentCorners 为 false 时边角控制器本身的边框颜色
cornerStrokeColor:        null,
// 边角控制器的样式,正方形或圆形
cornerStyle:          'rect',
// 指定边角控制器本身的虚线边框的模式元组(hasBorder 必须为 true)
// 第一个元素为实线,第二个为空白
cornerDashArray:          null,
// 如果为真,通过边角控制器来对对象进行缩放会以对象本身的中心点为准
centeredScaling:          false,
// 如果为真,通过边角控制器来对对象进行旋转会以对象本身的中心点为准
centeredRotation:         true,
// 对象的填充颜色
fill:                     'rgb(0,0,0)',
// 填充颜色的规则:nonzero 或者 evenodd
// @see https://developer.mozilla.org/zh-CN/docs/Web/SVG/Attribute/fill-rule
fillRule:                 'nonzero',
// 对象的背景颜色
backgroundColor:          '',
// 可选择区域被选择时(对象边角控制器区域),层级低于对象背景颜色
selectionBackgroundColor:          '',
// 设置后,对象将以笔触的方式绘制,此属性值即为笔触的颜色
stroke:                   null,
// 笔触的大小
strokeWidth:              1,
// 指定笔触虚线的模式元组(hasBorder 必须为 true)
// 第一个元素为实线,第二个为空白
strokeDashArray:          null,
Object 类常用方法
drawObject() 对象的绘制方法

drawObject() 方法内部会调用 _render() 方法,但是在 fabric.Object 基类中它 是 个空方法。 这意味着对象具体的绘制方法需要子类去 实现。即子类需要 重写 父 类的空 _render() 方法。

_onObjectAdded() 对象被添加到 Canvas 事件

这个方法非常重要,只要当一个对象被添加到 Canvas 中的时候,对象才可以具有 Canvas 的引用上下文, 对象的一些常用方法才能起作用。比如:Object.center() 方法,调用 它可以让一个对象居中到画布中央。 下面这段代码可以实现这个功能:

const canvas = new fabric.Canvas("canvas", {
  width: 500, height: 500,
})
const box = new fabric.Rect({
  left: 10, top: 10,
  width: 100, height: 100,
})
console.log(box.top, box.left)  // => 10, 10
box.center()
console.log(box.top, box.left)  // => 10, 10
canvas.add(box)

但是你会发现 box 并没有被居中,这就是因为:当一个对象(box)还没被添加到 Canvas 中的时候,对象上面 还不具有 Canvas 的上下文,所以调用的对象并不知道应该在哪个 Canvas 上绘制。我们可以看下 center() 方法的源代码:

center: function () {
  this.canvas && this.canvas.centerObject(this);
  return this;
},

正如上面所说,没有 canvas 的时候是不会调用到 canvas.centerObject() 方法,也就 实现不了居中。

所以解决方法也很简单,调换下 center() 和 add() 方法的先后顺序就好了:

const canvas = new fabric.Canvas("canvas", {
  width: 500, height: 500,
})
const box = new fabric.Rect({
  left: 10, top: 10,
  width: 100, height: 100,
})
canvas.add(box)
console.log(box.top, box.left)  // => 10, 10
box.center()
console.log(box.top, box.left)  // => 199.5, 199.5

「为什么不是 200,而是 199.5」—— 好问题,但是我不准备讲这个。有兴趣可以自己研究 下。

toObject() 对象的序列化

正向的把对象序列化是很简单的,只需要把你关注的对象上的属性拼成一个 JSON 返回即可 :

toObject: function(propertiesToInclude) {
  var NUM_FRACTION_DIGITS = fabric.Object.NUM_FRACTION_DIGITS,
      object = {
        type:                     this.type,
        version:                  fabric.version,
        originX:                  this.originX,
        originY:                  this.originY,
        left:                     toFixed(this.left, NUM_FRACTION_DIGITS),
        top:                      toFixed(this.top, NUM_FRACTION_DIGITS),
        width:                    toFixed(this.width, NUM_FRACTION_DIGITS),
        height:                   toFixed(this.height, NUM_FRACTION_DIGITS),
        // 省略其它属性
      };
  return object;
},

当调用对象的 toJSON() 方法时会使用 JSON.stringify(toObject()) 来将对象的属性 转换成 JSON 字符串

fromObject() 对象的反序列化

fromObject() 是 Object 的子类需要实现的反序列化方法,通常会调用 Object 类的默 认方法 _fromObject()

fabric.Object._fromObject = function(className, object, callback, extraParam) {
  var klass = fabric[className];
  object = clone(object, true);
  fabric.util.enlivenPatterns([object.fill, object.stroke], function(patterns) {
    if (typeof patterns[0] !== 'undefined') {
      object.fill = patterns[0];
    }
    if (typeof patterns[1] !== 'undefined') {
      object.stroke = patterns[1];
    }
    fabric.util.enlivenObjects([object.clipPath], function(enlivedProps) {
      object.clipPath = enlivedProps[0];
      var instance = extraParam ? new klass(object[extraParam], object) : new klass(object);
      callback && callback(instance);
    });
  });
};

这段代码做了下面一些事情:

  1. 通过类名(className 在 Object 的子类 fromObject 中指定)找到挂载在 fabric 命名空间上的对象的所属类
  2. 深拷贝当前对象,避免操作过程对修改源对象
  3. 处理、修正对象的一些特殊属性,比如:fill, stroke, clipPath 等
  4. 用所属类按新的对象属性构建一个新的对象实例(instance),返回给回调函数

噫,好像不对劲?反序列化入参不得是个 JSON 字符串吗。是的,不过 fabric.js 中并没 有在 Object 类中提供这个方法, 这个自己实现也很简单,将目标 JSON 字符串 parse 成 普通的 JSON 对象传入即可。

Canvas 类上面到是有一个画布整体反序列化的方法:loadFromJSON(),它做的事情就是 把一段静态的 JSON 字符串转成普通对象 后传给每个具体的对象,调用对象上面的 fromObject() 方法,让对象具有真正的渲染方法,再回绘到 Canvas 上面。

序列化主要用于 持久存储,反序列化则主要用于将持久存储的静态内容转换为 Canvas 中可操作的 2d 元素,从而可以实现将某 个时刻画布上的状态还原的目的

如果你的存储够用的话,甚至可以将整个在 Canvas 上的绘制过程进行录制/回放

一些绘制过程中常见的功能也是通过序列化/反序列化来实现的,比如:撤销/重做

fabric 混入类

混入类(mixin)通常用来给对象添加额外的方法,通常这些方法和画布关系不大,比如: 一些无参方法,事件绑定等。 通常混入类会通过调用 fabric.util.object.extend() 方 法来给对象的 prototype 上添加额外的方法。

fabric.js 的事件绑定

混入类里面有一个很重要的文件:canvas_event.mixin.js,它的作用有以下几种:

  1. 为上层 Canvas 绑定原生浏览器事件
  2. 在合适的时机触发自定义事件
  3. 使用第三方库(event.js)绑定、模拟移动端手势操作事件
fabric.js 的鼠标移动(__onMouseMove())事件

__onMouseMove() 可以说是一个核心事件,对象的变换基本上都要靠它来计算距离才能实 现,我们来看看它是如何实现的

__onMouseMove: function (e) {
  this._handleEvent(e, 'move:before');
  this._cacheTransformEventData(e);
  var target, pointer;

  if (this.isDrawingMode) {
    this._onMouseMoveInDrawingMode(e);
    return;
  }

  if (!this._isMainEvent(e)) {
    return;
  }

  var groupSelector = this._groupSelector;

  // We initially clicked in an empty area, so we draw a box for multiple selection
  if (groupSelector) {
    pointer = this._pointer;

    groupSelector.left = pointer.x - groupSelector.ex;
    groupSelector.top = pointer.y - groupSelector.ey;

    this.renderTop();
  }
  else if (!this._currentTransform) {
    target = this.findTarget(e) || null;
    this._setCursorFromEvent(e, target);
    this._fireOverOutEvents(target, e);
  }
  else {
    this._transformObject(e);
  }
  this._handleEvent(e, 'move');
  this._resetTransformEventData();
},

注意看源码的时候要把握到重点,一点不重要的就先忽略,比如:缓存处理、状态标识。我 们只看最核心 的部分,上面这段代码里面显然 _transformObject() 才是一个核心方法 。我们深入学习下。

/**
 * 对对象进行转换(变形、旋转、拖动)动作,e 为当前鼠标的 mousemove 事件,
 * **transform** 表示要进行转换的对象(mousedown 时确定的)在 `_setupCurrentTransform()` 中封装过,
 * 可以理解为对象 **之前** 的状态,再调用 transform 对象中对应的 actionHandler
 * 来操作画布中的对象,`_performTransformAction()` 可以对 action 进行检测,如果对象真正发生了变化
 * 才会触发最终的渲染方法 requestRenderAll()
 * @private
 * @param {Event} e 鼠标的 mousemove 事件
 */
_transformObject: function(e) {
  var pointer = this.getPointer(e),
      transform = this._currentTransform;

  transform.reset = false;
  transform.shiftKey = e.shiftKey;
  transform.altKey = e[this.centeredKey];

  this._performTransformAction(e, transform, pointer);
  transform.actionPerformed && this.requestRenderAll();
},

我已经把注释添加上了,主要的代码实现其实是在 _performTransformAction() 中实现 的。

_performTransformAction: function(e, transform, pointer) {
  var x = pointer.x,
      y = pointer.y,
      action = transform.action,
      actionPerformed = false,
      actionHandler = transform.actionHandler;
      // actionHandle 是被封装在 controls.action.js 中的处理器

  if (actionHandler) {
    actionPerformed = actionHandler(e, transform, x, y);
  }
  if (action === 'drag' && actionPerformed) {
    transform.target.isMoving = true;
    this.setCursor(transform.target.moveCursor || this.moveCursor);
  }
  transform.actionPerformed = transform.actionPerformed || actionPerformed;
},

这里的 transform 对象是设计得比较精妙的地方,它封装了对象操作的几种不同的类 型,每种类型 对应的有不同的动作处理器(actionHandler),transform 对象就充当了一 种对于2d元素进行操作 的 上下文,这样设计可以得得事件绑定和处理逻辑分离,代码 具有更高的内聚性。

我们再看看上面注释中提到的 _setupCurrentTransform() 方法,一次 transform 开始 与结束 正好对应着鼠标的按下(onMouseDown)与松开(onMouseUp)两个事件。

我们可以从 onMouseDown() 事件中顺藤摸瓜,找到构造 transform 对象的地方:

_setupCurrentTransform: function (e, target, alreadySelected) {
  var pointer = this.getPointer(e), corner = target.__corner,
      control = target.controls[corner],
      actionHandler = (alreadySelected && corner) 
              ? control.getActionHandler(e, target, control) 
              : fabric.controlsUtils.dragHandler,
      transform = {
        target: target,
        action: action,
        actionHandler: actionHandler,
        corner: corner,
        scaleX: target.scaleX,
        scaleY: target.scaleY,
        skewX: target.skewX,
        skewY: target.skewY,
      };

  // transform 上下文对象被构造的地方
  this._currentTransform = transform;
  this._beforeTransform(e);
},

control.getActionHandler 是动态从 default_controls.js 中按边角的类型获取的:

边角类型 控制位置 动作处理器(actionHandler) 作用
ml 左中 scalingXOrSkewingY 横向缩放或者纵向扭曲
mr 右中 scalingXOrSkewingY 横向缩放或者纵向扭曲
mb 下中 scalingYOrSkewingX 纵向缩放或者横向扭曲
mt 上中 scalingYOrSkewingX 纵向缩放或者横向扭曲
tl 左上 scalingEqually 等比缩放
tr 右上 scalingEqually 等比缩放
bl 左下 scalingEqually 等比缩放
br 右下 scalingEqually 等比缩放
mtr 中上变形 controlsUtils.rotationWithSnapping 旋转

对照上面的边角控制器图片更好理解。

这里我想多说一点,一般来讲,像这种上层的交互功能,做为一个 Canvas 库通常是不会封 装好的。 但是 fabric.js 却帮我们做好了,这也验证了它自己定义里面的一个关键词:** 可交互的**,正 是因 为它通过边角控制器封装了见的对象操作,才使得 Canvas 对象可以 与用户进行交互。我们普通开发者不需要关心细节,配置一些通用参数就能实现功能。

fabric.js 的自定义事件

fabric.js 中内置了很多自定义事件,这些事件都是我们常用的,非原子事件。对于日常开 发来说非常方便。

对象上的 24 种事件
画布上的 5 种事件

明白了上面这几个核心模块的工作原理,再使用 fabric.js 来进行 Canvas 开发就能很快 入门, 实际上 Canvas 开发并不难,难的是编程思想和方式的转变。

几个需要注意的地方

  1. fabric.js 源码没有使用 ES 6,没使用 TypeScript,所以在看代码的时候还是很不方便的,推荐使用 jetbrains 家的 IDE:IntelliJ IDEA 或 Webstorm 都是支持对 ES 6 以下的 JavaScript 代码进行 静态分析的,可以使用跳转到定义、调用层级等功能,看源代码会很方便
  2. fabric.js 源码中很多地方用到 Canvas 的 save() 和 restore() 方法,可以查看这个链接了解更多 查看
  3. 如果你之前从来没有接触过 Canvas 开发,那我建议去看看 bilibili 上萧井陌录的一节的关于入门游戏开发的 视频教程,不要一 开始就去学习 Canvas 的 API,先了解概念原理性的东西,最后再追求细节

从实际案例讲 Deno 的应用场景

1970-01-01 08:00:00

此篇文章实际上就是《前端开发的瓶颈与未来》的番外篇。主要想从实用的角度给大家介绍下 Deno 在我们项目中的应用案例,现阶段我们只关注应用层面的问题,不涉及过于底层的知识。

简介

deno

我们从它的官方介绍里面可以看出来加粗的几个单词:secure, JavaScript, TypeScript。简单译过来就是:

一个 JavaScript 和 TypeScript 的安全运行时

那么问题来了,啥叫运行时(runtime)?可以简单的理解成可以执行代码的一个东西。那么 Deno 就是一个可以执行 JavaScript 和 TypeScript 的东西,浏览器就是一个只能执行 JavaScript 的运行时。

特性

安装

Mac/Linux 下命令行执行:

curl -fsSL https://deno.land/x/install/install.sh | sh

也可以去 Deno 的官方代码仓库下载对应平台的源(可执行)文件,然后将它放到你的环境变量里面直接执行。如果安装成功,在命令行里面输入:deno --help 会有如下输出:

➜  ~ deno --help
deno 1.3.0
A secure JavaScript and TypeScript runtime

Docs: https://deno.land/manual
Modules: https://deno.land/std/ https://deno.land/x/
Bugs: https://github.com/denoland/deno/issues
...

以后如果想升级可以使用内置命令 deno upgrade 来自动升级 Deno 版本,相当方便了。

Deno 内置命令

Deno 内置了丰富的命令,用来满足我们日常的需求。我们简单介绍几个:

deno run

直接执行 JS/TS 代码。代码可以是本地的,也可以是网络上任意的可访问地址(返回JS或者TS)。我们使用官方的示例来看看效果如何:

deno run https://deno.land/std/examples/welcome.ts

如果执行成功就会返回下面的信息:

➜  ~ deno run https://deno.land/std/examples/welcome.ts
Download https://deno.land/std/examples/welcome.ts
Warning Implicitly using latest version (0.65.0) for https://deno.land/std/examples/welcome.ts
Download https://deno.land/[email protected]/examples/welcome.ts
Check https://deno.land/[email protected]/examples/welcome.ts
Welcome to Deno 🦕

可以看到这段命令做了两个事情:1. 下载远程文件 2. 执行里面的代码。我们可以通过命令查看这个远程文件里面内容到底是啥:

➜  ~ curl https://deno.land/[email protected]/examples/welcome.ts
console.log("Welcome to Deno 🦕");

不过需要注意的是上面的远程文件里面没有 显示的 指定版本号,实际下载 std 中的依赖的时候会默认使用最新版,即:[email protected] ,我们可以使用 curl 命令查看到源文件是 302 重定向到带版本号的地址的:

➜  ~ curl -i https://deno.land/std/examples/welcome.ts
HTTP/2 302 
date: Fri, 14 Aug 2020 01:53:06 GMT
content-length: 0
set-cookie: __cfduid=d3e9dfbd32731defde31eba271f19933b1597369985; expires=Sun, 13-Sep-20 01:53:05 GMT; path=/; domain=.deno.land; HttpOnly; SameSite=Lax; Secure
location: /[email protected]/examples/welcome.ts
x-deno-warning: Implicitly using latest version (0.65.0) for https://deno.land/std/examples/welcome.ts
cf-request-id: 048c44c2dc000019dd710cc200000001
expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
server: cloudflare
cf-ray: 5c270a4afd5719dd-SIN

header 头中的 location 就是实际文件的下载地址:

location: /[email protected]/examples/welcome.ts

这就涉及到一个问题:实际使用的时候到底应不应该手动添加版本号?一般来说如果是生产环境的项目引用一定要是带版本号的,像这种示例代码里面就不需要了。

上面说到 Deno 也可以执行本地的,那我们也试一试,写个本地文件,然后 运行它:

➜  ~ echo 'console.log("Welcome to Deno <from local>");' > welecome_local.ts
➜  ~ ls welecome_local.ts 
welecome_local.ts
➜  ~ deno run welecome_local.ts 
Check file:///Users/zhouqili/welecome_local.ts
Welcome to Deno <from local>

可以看到输出了我们想要的结果。

这个例子太简单了,再来个复杂点的吧,用 Deno 实现一个 Http 服务器。我们使用官方示例中的代码:

import { serve } from "https://deno.land/[email protected]/http/server.ts";
const s = serve({ port: 8000 });
console.log("http://localhost:8000/");
for await (const req of s) {
  req.respond({ body: "Hello World\n" });
}

保存为 test_serve.ts,然后使用 deno run 运行它,你会发现有报错信息:

➜  ~ deno run test_serve.ts 
Download https://deno.land/[email protected]/http/server.ts
Download https://deno.land/[email protected]/encoding/utf8.ts
Download https://deno.land/[email protected]/io/bufio.ts
Download https://deno.land/[email protected]/_util/assert.ts
Download https://deno.land/[email protected]/async/mod.ts
Download https://deno.land/[email protected]/http/_io.ts
Download https://deno.land/[email protected]/async/deferred.ts
Download https://deno.land/[email protected]/async/delay.ts
Download https://deno.land/[email protected]/async/mux_async_iterator.ts
Download https://deno.land/[email protected]/async/pool.ts
Download https://deno.land/[email protected]/textproto/mod.ts
Download https://deno.land/[email protected]/http/http_status.ts
Download https://deno.land/[email protected]/bytes/mod.ts
Check file:///Users/zhouqili/test_serve.ts
error: Uncaught PermissionDenied: network access to "0.0.0.0:8000", run again with the --allow-net flag
    at unwrapResponse (rt/10_dispatch_json.js:24:13)
    at sendSync (rt/10_dispatch_json.js:51:12)
    at opListen (rt/30_net.js:33:12)
    at Object.listen (rt/30_net.js:204:17)
    at serve (server.ts:287:25)
    at test_serve.ts:2:11

PermissionDenied 意思是你没有网络访问的权限,可以使用 --allow-net 的标识来允许网络访问。这就是文章开头特性里面提到的默认安全。

默认安全就是说被 Deno 执行的代码会默认被放进一个沙箱中执行,代码使用到的 API 接口都受制于 Deno 的宿主环境,Deno 当然是有网络访问、文件系统等能力的。但是这些系统级别的访问需要 deno 命令的 执行者 授权。

这个权限控制很多人觉得没必要,因为当我们运行代码时提示了受限,我们肯定手动添加上允许然后再执行嘛。但是区别是 Deno 把这个授权交给了执行者,好处就是如果执行的代码是第三方的,那么执行者就可以主动拒绝一些危险性很高的操作。

比如我们安装一些命令行工具,而一般命令行工具都是不需要网络的,我们就可以不给它网络访问的权限。从而避免了程序偷偷地上传/下载文件。

deno eval

执行一段 JS/TS 字符串代码。这个和 JavaScript 中的 eval 函数有点类似。

➜  ~ deno eval "console.log('hello from eval')"
hello from eval

deno install

安装一个 deno 脚本,通常用来安装一个命令行工具。举个例子,在之前的 Deno 版本中有一个命令特别好用:deno xeval 可以按行执行 eval 命令,类似于 Linux 中的 xargs 命令。后来这个内置命令被移除了,但是 deno 的开发人员编写了一个 deno 脚本,我们可以通过 install 命令安装它。

➜  ~ deno install -n xeval https://deno.land/[email protected]/examples/xeval.ts
Download https://deno.land/[email protected]/examples/xeval.ts
Download https://deno.land/[email protected]/flags/mod.ts
Download https://deno.land/[email protected]/io/bufio.ts
Download https://deno.land/[email protected]/bytes/mod.ts
Download https://deno.land/[email protected]/_util/assert.ts
Check https://deno.land/[email protected]/examples/xeval.ts
✅ Successfully installed xeval
/Users/zhouqili/.deno/bin/xeval
➜  ~ xeval
xeval

Run a script for each new-line or otherwise delimited chunk of standard input.

Print all the usernames in /etc/passwd:
  cat /etc/passwd | deno run -A https://deno.land/std/examples/xeval.ts "a = $.split(':'); if (a) console.log(a[0])"

A complicated way to print the current git branch:
  git branch | deno run -A https://deno.land/std/examples/xeval.ts -I 'line' "if (line.startsWith('*')) console.log(line.slice(2))"

Demonstrates breaking the input up by space delimiter instead of by lines:
  cat LICENSE | deno run -A https://deno.land/std/examples/xeval.ts -d " " "if ($ === 'MIT') console.log('MIT licensed')",

USAGE:
  deno run -A https://deno.land/std/examples/xeval.ts [OPTIONS] <code>
OPTIONS:
  -d, --delim <delim>       Set delimiter, defaults to newline
  -I, --replvar <replvar>   Set variable name to be used in eval, defaults to $
ARGS:
  <code>
[]

-n xeval 表示全局安装的命令行名称,安装完以后你就可以使用 xeval 了。

举个例子,我们使用 xeval 过滤日志文件,仅仅展示 WARN 类型的行:

➜  ~ cat catalina.out | xeval "if ($.includes('WARN')) console.log($.substring(0, 40)+'...')"
2020-08-12 13:37:39.020  WARN 202 --- [I...
2020-08-12 13:37:39.020  WARN 202 --- [I...
2020-08-12 13:37:39.019  WARN 202 --- [I...
2020-08-12 13:34:42.822  WARN 202 --- [o...
2020-08-12 13:34:42.822  WARN 202 --- [o...
2020-08-12 13:34:42.814  WARN 202 --- [o...
2020-08-12 13:34:42.805  WARN 202 --- [o...

$ 美元符表示当前行,程序会自动按行读取让执行 xeval 命令后面的 JS 代码。

catalina.out 是我本地的一个文本日志文件。你可能会觉得这样挺麻烦的,直接 | grep WARN 不香嘛?但是 xeval 的可编程性就高很多了。

deno test

deno 内置了一个简易的测试框架,可以满足我们日常的单元测试需求。我们写一个简单的测试用例试试,新建一个文件 test_case.ts,保存下面的内容:

import { assertEquals } from "https://deno.land/std/testing/asserts.ts";

Deno.test("1 + 1 在任何情况下都不等于 3", () => {
    assertEquals(1 + 1 == 3, false)
    assertEquals("1" + "1" == "3", false)
})

使用 test 命令跑这个测试用例:

➜ deno test test_case.ts
Check file:///Users/zhouqili/.deno.test.ts
running 1 tests
test 1 + 1 在任何情况下都不等于 3 ... ok (3ms)

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (3ms)

可以看到测试通过了。

还有其它很多好用的命令,但是在我并没用太多的实际使用经验,就不多介绍了。

实战

上面说了这么多基础知识,终于可以讲点实际应用场景了。我们在自己的一个 SDK 项目中使用了 Deno 来做自动化单元测试的任务。整个流程走下来还是挺流畅的。代码就不放出来了,我只简单的说明下这个 SDK 需要做哪些事情,理想的开发流程是什么样的。

  1. SDK 以 NPM 包的形式发布,给调用者使用
  2. SDK 主要提供一些封装方法,比如:网络请求、事件发布订阅系统等
  3. SDK 的代码通常不依赖 DOM 接口,并且调用的宿主环境方法与 Deno 兼容
  4. 测试用例不需要在浏览器里面跑,使用 Deno 在命令行中自动化完成
  5. 如果可以最好能做到浏览器使用可以独立打包成 UMD 模块,NPM 安装则可以直接引用 ES 版模块

如果你的场景和上面的吻合,那么就可以使用 Deno 来开发。本质上讲我们开发的时候写的还是 TypeScript,只是需要我们在发布 NPM 包的时候稍微的进行一下处理即可。

我们以实现一个 fetch 请求的封装方法为例来走通整个流程。

初始化一个 NPM 包

➜  ~ mkdir mysdk
➜  ~ cd mysdk 
➜  mysdk npm init -y

建立好文件夹目录,及主要文件:

➜  mysdk mkdir src tests
➜  mysdk touch src/index.ts
➜  mysdk touch src/request.ts 
➜  mysdk touch tests/request.test.ts

如果你使用的是 vscode 编辑器,可以安装好 deno 插件(denoland.vscode-deno),并且设置 deno.enabletrue。你的目录结构应该是这样的:

├── package.json
├── src
│   ├── index.ts
│   └── request.ts
└── tests
    └── request.test.ts

index.ts 为对外提供的导出 API。

初始化 tsconfig

使用 tsp --init 来初始化项目的 typescript 配置:

tsc --init

更新 tsconfig.json 为下面的配置:

{
  "compilerOptions": {
    "target": "ES5",
    "lib": ["es6", "dom", "es2017"],
    "declaration": true,
    "outDir": "./build",
    "strict": true,
    "allowUmdGlobalAccess": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": [
    "src/**/*.ts"
  ]
}

注意指定 outDirbuild 方便我们将编译完的 JS 统一管理。

编写 request 方法

为了演示,这里就简单写下。request.ts 代码实现如下:

export async function request(url: string, options?: Partial<RequestInit>) {
    const response = await fetch(url, options)
    return await response.json()
}

调用端封闭好 GET/POST 请求的快捷方法,并且从 index.ts 文件导出:

import {request} from "./request.ts";

export async function get(url: string, options?: Partial<RequestInit>) {
    return await request(url, {
        ...options,
        method: "GET"
    })
}

export async function post(url: string, data?: object) {
    return await request(url, {
        body: JSON.stringify(data),
        method: "POST"
    })
}

tests/request.test.ts 目录写上单元测试用例:

import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
import {get, post} from "../src/index.ts";

Deno.test("request 正常返回 GET 请求", async () => {
    const data = await get("http://httpbin.org/get?foo=bar");
    assertEquals(data.args.foo, "bar")
})

Deno.test("request 正常返回 POST 请求", async () => {
    const data = await post("http://httpbin.org/post", {foo: "bar"});
    assertEquals(data.json.foo, "bar")
})

最后在命令行使用 deno test 命令跑测试用例。注意添加 --allow-net 参数来允许代码访问网络:

➜  mysdk deno test --allow-net tests/request.test.ts
Check file:///Users/zhouqili/mysdk/.deno.test.ts
running 2 tests
test request 正常返回 GET 请求 ... ok (632ms)
test request 正常返回 POST 请求 ... ok (342ms)

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (974ms)

我们可以看到测试都通过了,下面就可以安心的发布 NPM 包了。

需要注意一点 Deno 写 TypeScript 的时候严格要求导入的 文件路径 必须添加 .ts 后缀。但是 TS 语言并不需要显式的添加这个后缀,TS 认为引入(import)的是一个 模块 而不是文件。这一点 TS 做的比较极端,tsc 要求你必须删除掉 .ts 后缀才能编译通过,这个我个人认为是非常不合理的。但是 Deno 有它的考虑,因为没有严格的文件名后缀引起程序 BUG 我自己也遇到过。

发布 NPM 包

上面的几步都相对流畅,唯独到发布 NPM 包这一步就比较麻烦。因为本质上讲 Deno 只是 TypeScript/JavaScript 的运行时,并不兼容 NPM 这种包管理工具。而且 NPM 是为 Node.JS 设计的,它也没有办法直接发布 TypeScript 的包,我们只能把 TypeScript 编译成 JavaScript 再进行发布。

发布这里我们的需求有两点:

  1. 可以将最终的代码包合成到一个文件中编译成 UMD,浏览器引入这个脚本可以通过全局变量 window.MySDK 访问到
  2. 通过 NPM 安装的最好默认使用 ESModule

第二个简单,我们直接使用 tsc 的命令就可以完成:

tsc -m esnext -t ES5 --outDir build/esm

这时你会发现我上面提到的问题,tsc 报错了:

➜  mysdk tsc -m esnext -t ES5 --outDir build/esm
src/index.ts:1:23 - error TS2691: An import path cannot end with a '.ts' extension. Consider importing './request' instead.

1 import {request} from "./request.ts";
                        ~~~~~~~~~~~~~~

说我不能使用 .ts

这就尴尬了,deno 要求我必须添加,TS 又要求我不能添加。你到底想让人家怎么样嘛?

而且还有一个问题,我们现在实现的功能还很简单,引入的文件很少,可以手动修改下。但是以后功能多了怎么办?文件很多手动修改肯定不是办法啊。实在不行还是算了,不用 Deno 了?

其实嘛,解决方法还是有的,上面我们不是介绍过 Deno 安装脚本功能了吗。我们自己写个脚本放在 NPM Script 里面,每次编译发布前这个脚本自动把 .ts 去掉,发布完再自动改回来不就好了。

于是乎我自己写了一个 Deno 脚本,专门用来给项目的文件批量添加或者删除引用路径上面的 .ts 后缀:

源代码我就不全部贴出来了,简单讲就是用正则匹配出每个 ts 文件中的头部的 import 语句,按命令传入的参数去处理后缀就可以了。代码我放到了 gist 上,有兴趣的可以研究下:

https://gist.github.com/keelii/d95492873f35f96d95f3a169bee934c6

你可以使用下面的命令来安装并使用它:

deno install --allow-read --allow-write -f -n deno_ext https://gist.githubusercontent.com/keelii/d95492873f35f96d95f3a169bee934c6/raw/9736099cb47ef706e6c184e83c78fdfc822810dd/deno_ext.ts

使用 deno_ext 命令即可:

 ~ deno_ext
✘ error with command.

Remove or restore [.ts] suffix from your import stmt in deno project.

Usage:
  deno_ext remove <files>...
  deno_ext restore <files>...
Examples:
  deno_ext remove **/*.ts
  deno_ext restore src/*.ts

工具告诉你如何使用它,remove/restore 两个子命令+目标文件即可。

我们配合 tsc 可以实现发布时自动更新后缀,发布完还原回去,参考下面的 NPM script:

{
  "scripts": {
    "proc:rm_ext": "deno_ext remove src/*.ts",
    "proc:rs_ext": "deno_ext restore src/*.ts",
    "tsc": "tsc -m esnext -t ES5 --outDir build/esm",
    "build": "npm run proc:rm_ext && npm run tsc && npm run proc:rs_ext"
  }
}

我们使用 npm run build 命令就可以完成打包 ESModule 的功能:

➜  mysdk npm run build

> [email protected] build /Users/zhouqili/mysdk
> npm run proc:rm_ext && npm run tsc && npm run proc:rs_ext


> [email protected] proc:rm_ext /Users/zhouqili/mysdk
> deno_ext remove src/*.ts

Processing remove [/Users/zhouqili/mysdk/src/index.ts]
Processing remove [/Users/zhouqili/mysdk/src/request.ts]

> [email protected] tsc /Users/zhouqili/mysdk
> tsc -m esnext -t ES5 --outDir build/esm


> [email protected] proc:rs_ext /Users/zhouqili/mysdk
> deno_ext restore src/*.ts

Processing restore [/Users/zhouqili/mysdk/src/index.ts]
Processing restore [/Users/zhouqili/mysdk/src/request.ts]

最终打包出来的文件都在 build 目录里面:

build
└── esm
    ├── index.d.ts
    ├── index.js
    ├── request.d.ts
    └── request.js

接下来我们还需要将源代码打包成单独的一个 UMD 模块,并展出到全局变量 window.MySDK 上面。虽然 TypeScript 是支持编译到 UMD 格式模块的,但是它并不支持将源代码 bundle 到一个文件里面,也不能添加全局变量引用。因为本质上讲 TypeScript 是一个编译器,只负责把模块编译到支持的模块规范,本身没有 bundle 的能力。

但是实际上当你选择 --module=amd 时,TypeScript 其实是可以把文件打包 concat 到一个文件里面的。但是这个 concat 只是简单地把每个 AMD 模块拼装起来,并没有 rollup 这类的专门用来 bundle 模块的高级功能,比如 tree-shaking 什么的。

所以想达到我们目标还得引入模块 bundler 的工具,这里我们使用 rollup 来实现。什么?你问我为啥不用 webpack?别问,问就是「人生苦短,学不动了」。

rollup 我们也就不搞什么配置文件了,越简单越好,直接安装 devDependencies 依赖:

npm i rollup -D

然后在 package.json 中使用 rollup 把 tsc 编译出来的 esm 模块再次 bundle 成 UMD 模块:

"scripts": {
    "rollup:umd": "./node_modules/.bin/rollup build/esm/index.js --file build/umd/index.bundle.js --format umd --name 'MySDK'"
}

然后可以通过执行 npm run rollup:umd 来实现打包成 UMD 并将 API 绑定到全局变量 MySDK 上面。我们可以直接将 build/umd/index.bundle.js 的代码复制进浏览器控制台执行,然后 看看 window 上有没有这个 MySDK 变量,不出意外的话,就会看到了。

mysdk-window-global-ns

我们在 index.ts 文件中 export 了两个 function:get/post 都有了。来试试看能不能运行起来

注意:有的浏览器可能还不支持 async/await,所以我们使用了 Promise 来发送请求

mysdk-get-request

到此,我们所有的需求都满足了,至少对于开发一个 SDK 级别的应用应该是没问题了。相关代码可以参考这里:https://github.com/keelii/mysdk

需要注意的几个问题:

  1. 我们代码中能使用 fetch 的原因是 Deno 和浏览器都支持这个 API,对于浏览器支持 Deno 不支持的就没办法写测试用例了,比如:LocalStorage 目前 Deno 还不支持
  2. 用 Deno 脚本移除 .ts 的后缀这个操作是比较有风险的,如果你的项目比较大,就不建议直接这么处理了,这个脚本目前也只在我们一个项目里面实际用到过。正则匹配换后缀这种做法总不是 100% 安全的

前端开发的瓶颈与未来之路

1970-01-01 08:00:00

前端开发的瓶颈到底在哪里,前端技术是否已经走到一个十字路口,全栈化的系统架构是否能改变目前的窘境?本文将根据作者自身的开发经历谈谈当下前端开发中遇到的一些问题和想法。

引子

近两年我一直在思考的一个问题:

如果前端不用考虑性能问题、不用考虑终端兼容性、不用考虑历史遗留问题,甚至不用考虑具体技术实现...

如果我们假设自己有丰富的技术储备,同时不用考虑上面的问题,那么前端究竟 做出什么样有价值的东西?

我们把时间拉到 5 年前...

如果你「那时」还是前端开发的话。上面的问题肯定是你不得不面临的典型问题。甚至是当时前端开发的意义所在。

但是随着时间的推移,前端技术的更新迭代,以及互联网的发展。你会发现这些曾经的问题似乎已经不再是问题,或者说在能预见的未来 可能 不再是问题。

页面加载性能可能不再是问题,技术上有了 HTTP2,基建上有了 5G,硬盘也越来越快。

兼容性问题慢慢淡出大家的视角,Chrome 一家独大,微软也不得不向它靠拢。

很多前端开发已经具备了后端(或者说多端)的技术能力,技术储备也可能不是问题,当然前提是你能招到人。

定义

到底什么是前端开发,前端与后端的界限在哪里?我在三年前对它的定义是:

前端为 界面、交互展示负责; 后端为 数据、业务逻辑负责;

不过现在看来似乎已经过时了,我越来越觉得不应该有这样一个清晰的界限把前后端分割开来,尤其是技术层面(除了职能层面的界限有利于协作以外)。这就好比说:如果你不能打破规则,那就必将被规则束缚。

我一直认为程序员应该对新的技术、工具、理念有比平常人更快的适应能力。举个简单的例子,我以前写代码通常使用 tab 缩进,后来大家都建议使用空格,刚开始尝试换成空格肯定是拒绝的,因为让人改变习惯是一件很难的事情。但是当你真正为了改变做出实践的时候,往往就会发现一条新大路。同样还有加不加分号的问题。

现在回过头来再看,前端在整个系统层面担任的角色至少应该是整个视图 View 层面的。视图层面的技术更接近软件系统的上层,更感性。感性的东西就是说一个颜色,我觉得好看,他觉得不好看,完全属于个人情感诉求。所以前端更注重与UI、交互 以及整个产品层面需要解决的问题。优秀的前端必然要具备敏锐的产品洞察能力。

当然这还只是前端最基础的职责所在。同时前端做为最接近产品的技术角色,技术才是前端真正的硬实力。

大约在去年一年的时间,我的岗位从前端转向了后端 Java 程序员的角色。虽然只做了一年的 Java 程序员,但是对我自身的技术提升而言是最多的一年。大家可能普遍的认为后端转前端比较容易,前端转后端会有门槛,实际上根据我自己的体验来讲并非如此。

Java 这门语言是商业化、成熟度特别高的语言。无论是语言本身,还是周边框架、工具都有一套非常成熟且层次分明的系统化抽象。如果你有两、三年的编程经验,突然让你上转写 Java 是非常容易的一件事情,尤其是写 Java web。Spring 框架已经为程序员屏蔽了很多复杂问题,而且已经事实上成为了各大互联网公司的主流框架选型。

我特意按我自己的学习线路绘制了一张 Java 版的程序员学习线路,仅供参考:

Java arch.png

我们可以清楚的看出来 Java 构建的整个体系最大的特点:它是渐进式的,一步一步地给开发者建立正向的引导。

当我处在在应用层阶段的时候,我需要关心的只是一些概念,方法,具备基础了以后就可以借助 Spring 框架入门,入门后就可以研究源码,你会发现 Spring 的本质核心类 DispatchServlet,从此 Servlet 就出现在了你的视野。我以前上学时理解不了 java 中 Servlet 的概念,后来参加了工作又学些了 Python,再次看到 Java 中的 Servlet 的时候瞬间就明白了它就是 Python 中的 uwsgi,就是一种接口,将编程语言和服务器网关链接起来的一种规范。

然后你就可以顺利进入下一环节,服务器/通信。这里你会发现整个网络编程的核心 Socket,同样以前上学的时候没理解 Socket 的概念,继续学习后你就会明白 Socket 其实就是操作系统提供给编程语言的一种能力,有了它就可以建立服务器与客户端之间的通信。在这一环节中你会学习到网络层 TCP/IP 协议,明白了 TCP/UDP 的区别,while (true) { socket.listen() } 建立 Socket 监听会有性能问题,此时你便进入下一个抽象层次,操作系统和计算机原理。

为了解决「while true」监听连接的性能问题,你会去学习多线程技术,了解并发的概念。你可能总会听到别人讨论并发和并行的区别。继续学习后,慢慢的你就会明白:并发多用来解决网络IO(硬盘)的效率问题,而并行则是为了更好的利用多/核处理器(CPU)的问题。这时你会发现这个阶段涉及到了很多的计算机硬件知识。内存分配、CPU计算、IO 复用等等。

像 Spring 这种框架才能真正意义上被称做 框架,因为它不仅仅解决了软件开发的问题,更重要的是 AOP/IoC 这类概念可以完全改变编程的一些理念。使用 Spring 开发 web 应用,联合 Java 构建出来的生态,整个开发流程就像呼吸一样自然。

Java 构建出来的软件开发体系就像是把程序员放进了一个一个的层次分明的小柜子里面,进去了以后你根本不需要关注外界是怎么样的,做好自己那部分工作就可以了。如果你对外界有兴趣可以一点点的顺藤摸瓜,跳出你原来的小柜子。即保证精力专注的同时又建立起一套有秩序的提升曲线。这一点是别的语言体系没有的。

实际上我在转 Java 之前对 Java 有着不小的误解,甚至转 Java 本身也不是我自己的想法。但当你真正转型成 Java 程序员后。看懂了数以百万行记的代码仓库、维护过每秒好几十万的 QPS 项目、见识过百行的 SQL 的时候,你才会对 Java 和软件开发产生一种敬畏之心,才会对技术才有了更深层次的理解。

这时候再回过头来看前端,看 JavaScript,才会发现它们之间的区别与特点。很多之前争论的东西也就有了结论。

瓶颈

我相信从事前端工作稍微长一点(5年以上)的人近两年都会有一种感觉:前端似乎没什么东西可以玩出花样了。这是因为很多东西都已经成为了前端事实上的主流,以前前端没有的基建慢慢的被完善。语言、框架、可视化、跨端、游戏、工具/自动化/工程化 这些领域都在发展。

语言方面 TypeScript 必然是主流,无论你愿意与否,你都将不得不使用它来写前端。框架方面 React 已经是事实上的主流了,没必要再做选择题。打包工具 Webpack 也是一家独大,虽然被很多人诟病,但是社区生态起来了,想改变就很难。跨端应用 Electron 也不用想了,VSCode 能做好你做不好那就不是选型的问题了。2D 游戏/绘图方面 PixiJS 6 已经在设计中了,3D 我个人认为就先别玩了。

这些看似成熟的体系实际上还是有很多可以挖掘的东西。如果你不深入研究,或许会认为过两年这些技术就稳定了前端就可以做到大一统的状态。这个想法可能就过于天真了,我举例解释下它们各自的瓶颈:

前/客户端框架的瓶颈

React(并不特指 React)虽然现在看起来是主流,但是它本身有很多问题是没解决的,甚至可以说是无解的。React 的本质只是一个 UI Library,并不是框架 Framework。框架要解决的问题是系统层面的不是某个抽象层面的。用 React 写过几个项目以后你就会认识到用 React 去写大型项目是非常麻烦的事情,React 本身并不解决 SPA 应用中数据流的问题,甚至没解决状态管理的问题(或者说状态管理本来就是个伪命题?)。一个很简单的父子组件之间状态共享的问题一直没有成熟的解决方案,hooks 这种方案更像是拆了东墙补西墙。

而且现在 React 社区弥漫着一种崇尚函数式编程的邪气,hooks 更像是一块遮羞布。多数人用 hooks 的原因仅仅是不想使用 Class,因为 Class 很臃肿,function 更简单。当然这个逻辑是没问题的。函数确实简单,但是如果你把一个函数里面写上几百行的代码,各种 hooks 用到飞起的时候,你才会回过头来反思如何组织代码。如果 Class 能以一种更好/更易于理解的方式去抽象那为什么不用呢?

后/服务端框架的瓶颈

前端框架如此,基于 Node.JS 的后端框架也好不到哪儿去,难道你真的想用 Express/Koa.js 去写大型的后端应用?这种量级的框架连 web 开发最简单的三层模型( 模型、视图、控制器)支持都不完整。当然你可能会说小型框架本来就只关注某一方面嘛,视图和模型层的东西可以用其它三方库解决。是的,确实可以这样,不过你不觉得 Node.JS 的第三方库有点太多了吗。正如 NestJS 在文档中提到的一个问题一样「很多 JavaScript 类库都没有高效地解决一个问题 架构。」React/Vue/Express/Koa 这些都是相对独立的点,没有一个东西能把他们连接起来形成一个面,形成一种框架级别的体系。这就是架构的问题。

这里多说一点,结合上面 Java 构建出来的生态,对比 Node.JS 的话。我借用自己打过的比喻:如果你低头看到的是 Node.JS,那么你抬头未必能看见 Java。假如你从事前端开发 2,3 年遇到瓶颈,想转学 Node.JS,你会学习 Exporess/Koa 这类框架,但是很快你就会发现一个严重的问题:没办法深入下去了。因为当你用 Express 写完一个页面后就面临着各种技术上的盲点,会让你无所适从。

我也尝试绘制一张我对 JavaScript/Node.JS 或者说大前端体系理解的一张图:

node-arch.png

JavaScript 体系看似前后端通吃,客户端、 服务端甚至桌面端皆有。但是最大的问题在于:没有一个东西能给他们建立起关系并发展成为一种体系。

插播一条娱乐看点,前两天写 Ruby on rails 框架的作者 DHH 发推并配图:

dhh.png

大意如下:

现在的年轻人在 web 开发的时候是这样的嘛?底层逻辑、纯手写连接池 + 纯手工 SQL、配置文件都放在了一起。天哪!(截图中使用的式TJ大神写的 Express 框架)

然后 TJ 大神也回复了:

tj.png

大意如下:

只有菜鸟玩家才能写出干净、简洁、高性能(黑 Ruby 性能)、见名知意的 SQL,而不是去写一个有15层的抽象。

两者的推特对话挺有意思,大家娱乐一下。

TypeScript 语言的瓶颈

TypeScript 也主流,但是持续关注 TS 到现在,我发现 TS 也遇到了瓶颈,这个瓶颈不仅来自于 TS 的设计目标与理念,更多的还是社区及 TC39。TS 的设计初衷是 JavaScript 的超集,由于本身要编译成 JS,这一点本质上限制了 TypeScript 的方向,设计者对于添加一个新特性会非常谨慎,一者怕与 TC39 ES proposal 冲突,二者要考编译到不同版本 JavaScript 的兼容性问题。以至于现在 TS 新的语言特性只会跟进 TC 39 发布的最新 ES proposal。但是我个人对于 TC 39 的效率及未来持怀疑态度,decorator 的提案一直还处于 Stage 2 的阶段,像这种其它语言都成为标配好几年的事情,现在 JavaScript 社区还在草案(stage-2)阶段。

普及下 ECMA 的标准的流程:

  1. stage-1:前期设想
  2. stage-2:正式提案(装饰器所在的阶段)
  3. stage-3:实现候选
  4. Stage-4:完成测试
  5. 各个浏览器 JS 引擎实现;TypeScript 实现

stage2-decorator.png

在这个问题上我认为其实也很好解决,开个脑洞:如果微软想借助编程语言一统浏览器和客户端是没有什么不可能的。并入 TC39 组织,开发真正属于 TypeScript 的原生引擎,奉天子以令不臣的方式也未尝不可。

近几年 Microsoft 对于开源的投入是肉眼可见的,微软要发力我相信很多东西都会有翻天覆地的变化。

打包工具的瓶颈

Webpack/Babel 就更不用说了,主流中的主流。但是也是问题最严重的一个。Webpack/Babel 的流行恰恰从反面证明了前端的基础设施有多么的烂。现在国外网友老天天叫喊着 Webpack/Babel is eval 也是挺值得深思的。我们引入了一个新工具来解决问题,却又在不经意之间产生了新问题。

前端构建工具问题的本质还是在于 Node.JS 的包管理工具的设计。这一点在 Node.JS 的作者 Ryan Dahl 关于 Deno 演讲《10 Things I Regret About Node.js》中也有过「官方」的承认。我相信任何一个实现过构建工具的人都被 Node gyp 打败过。node-sass, fsevent 的痛不必细说。更不用说万年被黑的 node_modules 了,你根本不知道一个简单的 npm install 命令会导致安装成千上万个 npm 包被安装到你的机器上。

ry-node-regret.png

当然每种编程语言对应的包管理工具都要解决依赖问题,而且这是一个普遍的问题,脚本/解释型编程语言尤为突出,Python/Ruby/PHP 都有这些类似的问题。或许 Go/Rust 这种把源代码编译打包成单个可执行文件的方式才是好的解决方式。

未来

从前人们总是抱怨 JavaScript 这门语言,黑它、讽刺它。但是我看到的是它在一点点变好。不仅是语言层面逐步完善,工具链生态日趋成熟,使用它的也人越来越多。大家对它的关注程度也在提高,整个 JavaScript 开发者的水平也在向更高更强的方向发展。生存环境只会淘汰那些老旧不再进化的事物,能适应变化的才会永存。

JavaScript 这门语言有两个其它 任何 编程语言都不具备的优点:

  1. 几乎 无所不在 且不用安装,有浏览器就有 JavaScript。脚本语言意味着它能被嵌入到任何宿主环境中去:Nginx、Native应用、硬件编程、物连网、嵌入式 都有它的身影
  2. 这门语言对于技术的更新迭代有着强大的 适应能力。JavaScript 本身的更新迭代速度导致它进化速度很多,语言上的新特性会很快被运用到生产环境。相比 Python 而言,这简直是做梦,Python 2 到 3 的转换没人能看到真正的时间表。

当下的前端开发状况不由得让我我想起苏东坡《晁错论》中的一段话:

天下之患,最不可为者,名为治平无事,而其实有不测之忧...

最大的问题在于,有些事物,从表面上看着平淡无奇,但实际上底层暗流涌动,似乎每一时刻都有着巨变的可能性。这也是前端开发最有趣也最有潜力的地方。

作为一名新时代的前端开发者,就是要在这看似风平浪静的表面之下,找到一些真正的突破点,兴许只是一个简单的想法,顺应时势然后造就出不斐的成就也说不定呢。

无论是前端还是后端、国内还是国外,技术才是真正的核心竞争力,只有技术革新才能提高生产力,而对于我们程序员来讲,编程则是唯一能提升硬实力的方法。只要你心中充满了热情,坚持下去总会走出一条自己的路子。

分享一段小经历

我在 2018 年有幸参加了 TypeScirpt 的推广大会,TypeScript 的作者 Anders Hejlsberg 亲自主讲。一位将近 60 岁的程序员在讲台上滔滔不绝的讲技术方案,TS 的设计理念。你真的很难想像这样一位处于「知天命」阶段的老头子(实际上很年轻)讲的东西。

typescript-2015.jpg

QA 环节有个年轻小伙问到 Anders「在中国做程序员很累、很难应该怎么坚持下去(类似这样的描述,细节记不清楚了)」的问题。

Anders 几乎毫不犹豫的说出了「Passion」这个单词。我瞬间就被打动了。因为在此之前我对于「激情」这个词的认识还停留在成功人士的演讲说辞层面,当 Anders 亲口说出 Passion 一词的时候,让人感觉真的是一字千金。

直到现在 Anders 还做为 TypeScript 的核心贡献者为它提交代码,到处奔走为 TypeScript 宣传。

我们再回到前端,那么未来的前端到底会发展成什么样?长期而言充满了未知数,谁也没法预测,但是短期来讲我比较关注几个东西:

如果你仔细研究一番,上面的这些新鲜东西,都是起源于前端,但又不把视野局限在前端。或许这就是前端未来的发展方向吧。

这几项技术我们会在后期的更新中会有专门的干货文章,敬请期待~

Java 并发与多线程教程

1970-01-01 08:00:00

注:此文翻译自 Java Concurrency and Multithreading Tutorial,本文只是首篇翻译

Java 中的并发是一个术语,涉及 Java 平台中的多线程、并发、并行等概念。包括 Java 并发工具,问题和解决方案。这个教程涵盖了多线程的核心概念、并发组成结构、并发的问题、成本与收益以及与 Java 多线程相关的问题。

什么是多线程?

多线程的意思是在同一个应用中有多个执行线程。线程就好比是你应用中的一个独立的 CPU。因此多线程的应用就好比一个拥有多个 CPU 的应用程序,这些 CPU 可以在同一时间执行不同的代码。

introduction-1

尽管一个线程并不等同于一个 CPU,但是通常一个 CPU 将会共享执行时间给多个线程,CPU 会在不同的线程之间来回切换,每个线程上执行一点。当然让应用中的线程执行在不同的 CPU 上也是可以的。

introduction-2

为什么要使用多线程

大家需使用多线程的原因有很多,最重要的有以下几点:

下面的章节中我将一一解释这几个原因的细节。

更好的利用单个 CPU

最常见的原因之一是能够更好地利用计算机中的资源,比如说,一个线程正在等待一个网络请求响应的同时,另一个线程可以利用 CPU 做别的事情。另外,如果计算机有多个 CPU 或者 CPU 有多个执行内核,那么多线程同样可以帮你更好的利用这些 CPU 内核。

更好的利用多个(核)CPU

如果计算机有多个 CPU 或者 CPU 有多个执行内核,那么你需要在应用中使用多线程来更好的利用到所有的 CPU 和多核 CPU。单个线程最多只能利用一个 CPU,就像我上面提到的,有时甚至不能完全地利用好一个 CPU。

更好的用户响应体验

另外一个使用多线程的原因是提供良好的用户体验。比如说,当你点击了一个 GUI 界面上的按钮,这个动作会触发一个网络请求,接着哪个线程来处理这个请求就非常关键了。如果你同时又使用这个处理请求的线程来更新 GUI 界面,然后当 GUI 线程等待请求响应时用户就会体验到 GUI 挂起的状态。作为替代方案,这个处理请求的线程可以单独创建成后台线程,这样的话 GUI 线程就可以用来同时响应其它请求。

更好的用户公允体验

第四个原因是在用户之间更公平的共享资源,想象一个例子,服务器接收客户端的请求,但是只有一个线程来处理这些请求。如果有一个客户端发送了一个请求并且处理了很久,然后其它客户端请求不得不等待那个请求结束。让每个请求都有一个属于自己的处理线程去执行,这样的话就不会有任何一个任务可以完全地霸占 CPU。

多线程与多任务

以前的计算机只有一个 CPU,并且在同一时间只能执行一个程序。许多的小型计算机并没有强大到能在同一时间执行多个程序,也没有尝试过这么设计。坦白地说,许多主机系统可以在同一时间执行多任务这比个人电脑已经提前好多年了。

多任务处理

后来多任务出现了,这意味着计算机可以同时执行多个程序(或者说任务、进程),这才真正意义上叫做同时执行。CPU 在多个程序之间被共享。操作系统在运行的程序中来回切换,每次执行一小会儿。

随着多任务处理给软件开发人员带来了新的挑战,程序不再假设 CPU 一直可用,其它如内存这样的计算机资源也一样。好的程序员会在不使用资源的时候释放它们,这样别的程序才可以使用到这些资源。

多线程处理

后来又出现了多线程。这意味着在程序内部你可以有多个执行线程。执行线程可以想象成 CPU 执行程序。当你有多个线程执行同一个程序时,就好比多个 CPU 在同一个程序中执行。

多线程并非易事

对于某些程序而言,多线程是一个非常好的提升性能的办法。然而多线程的使用相对于多任务来说具有更高的挑战。多个线程在同一个程序中执行,因此可以同时读取和写入相同的内存。这可能会导致一些单线程应用中不存在的问题。这些问题在单 CPU 的机器上可能不会被发现,因为两个线程永远不可能真正地同时执行。尽管如此,现代计算机可以拥有多核CPU,或者多个 CPU。这意味着不同的线程可以在不同核心的 CPU 上被同时执行。

java-concurrency-tutorial-introduction-1

如果没有合适的预防措施,这些问题就很可能会出现。程序行为甚至不能被预测。结果可能频繁地改变。因此对于程序员来说做好预防措施就变得非常重要—意味着需要去学习线程是如何访问到共享资源(内存、文件、数据库)的,这也是一个本教程要讲到的主题。

Java 中的多线程与并发

Java 是首个把多线程处理特性提供给开发者的编程语言之一。Java 在最开始的时候就提供了多线程处理的能力。因此 Java 程序员经常面临上面我们提到的问题。这也是我写这篇文章的初衷,做为我自己的学习记录的同时也希望其它 Java 程序员能从中受益。

本教程将主要关注 Java 中的多线程处理。但是有的多线程问题与分布式系统中多任务处理面临的问题很相似。所以教程中也会出现多任务与分布式系统的相关引用,因此教程使用「并发」而不是「多线程」这个关键字。

并发模型

第一种并发模型假定多个线程在同一个程序中执行并可以同享对象。这种并发模型被称做「共享状态的并发模型」,有很多并发语言的组件构成都支持这种并发模型。

然而,自从第一本 Java 并发书被写出以来并发构架设计已经发生了很多变化,甚至是从 Java 5并发工具包发布以来,并发构架设计也经历了很多的变化。

共享状态的并发模型会引发很多难以解决的并发问题,因此,另外一种被叫做「无共享/状态分离」的并发模型流行了起来。在状态分离的并发模型中线程之间不共享任何对象或数据。这样就可以避免很多在共享并发模型中的并发访问类题。

最新的如 Netty, Vert.x 和 Play,Akka, Qbit 等异步「状态分离」平台套件慢慢崭露头角。新的非阻塞并发算法也已经发布,新的非阻塞工具像 LMax Disrupter 也被加进了套件中。Java 7 中的 Fork 和 Join 框架也引入了新的函数式编程并行特性。

随着技术的不断发展,也是时候更新下这篇教程了。因此这篇教程再一次进入了重写状态。新的教程会在合适的时间发布。

Java 并发学习指引

如果你对 Java 并发还不是很了解,我建议你按下面的学习计划。你可以在左侧的菜单中找到所有主题的链接。

通用的并发与多线程理论:

Java 并发基础:

Java 并发中的经典问题:

Java 中用来解决上面问题并发体系:

更多主题:

漫谈哲学与编程

1970-01-01 08:00:00

谈到哲学,多数人都会直觉性的认为它是很高深的一门学问。实际上大多数情况并非如此,哲学研究的往往是非常简单的一些命题,而这些命题在常人看来可能并没有现实意义。

比如说:到底是先有鸡还是先有蛋的问题;比如说:一个号称只给不能给自己理发的人理发的理发师到底能不能给自己理发的问题。当然本文的目的并不在于讨论这两个问题,我们来聊聊几个稍微简单一些的概念:

哲学中的理性与感性

理性是超越的,本质在于追求无限

超越 的意思是说理性本身不依赖任何现实或者经验社会中的任何对象,无限 实际上就是说理性本身需要达到的某种理想状态。

比方说:「100%的金」 就是一种无限状态。我们不使用任何经验就可以判断出 100% 的金是必然有的,概念上没人能否定这一点。

但是运用在经验社会中的知识来判断,这个命题就是不正确的,或者说不具有普遍的正确性。因为我们知道无论人类的技术如何高超也无法制造出来 100% 的金。即使到 99.99% 逻辑上也没到达 100%。

这个时候人们对于类似的事情就会产生了不同的反应。有的人会因为理想状态达不到而反向地认为原来的命题是错误的;有的人内心则有一种说不清道不明的东西指引着他,不会因为到达不到无限状态而肯定整个命题。

这个问题也一直困扰了我很久,因为在现实生活中在你看来很多明显正确的事情忽出现了一个反例,结果就会有一堆人来告诉你你错了。

德国哲学家康德在《纯粹理性批判》这本书中给出了一种解释:

理性的调节性是引导经验去追求无限,追求绝对,但是永远也达不到。达不到也有作用 — 它使得经验科学不断的前进,并且有了明确的目的和方向…

类似的哲学观点好就好在一但明白了其中的本质和它阐述的真理以后,它就可以在某种层次上解释经验世界的各种现象。这或许就是大家说的哲学是任何其它学科的奠基,是第一学科的原因。

对应的在编程领域也有一些无限的概念,对于多数前端工程师来讲「实现一个无限级的下拉菜单」似乎也在表达着一种无限状态。当然用户在使用的时候根本不可能用到无限级的菜单,无限级的菜单在交互方面也也是极其反人类的,一步可以做到的事情没人愿意多增加一步。但是为什么程序员们热衷于实现这种类似的无限状态。实际上这就是理性的力量,总有一种说不清楚的力量在引导着你,你也没法解释。

理解了这一点你就会有一个很简单的评判程序好坏的论点,即:程序或者代码是否表现了某种无限状态?如果你的程序函数里面只是几个简单的 if else,那你有没有考虑过如果当输入不断的增加或者变化时,原来的代码是否还可以正常返回。或者说在不考虑硬件等客观条件的前提下,你的程序是否存在极限状态。

我们经常在知乎或者其它论坛上争论一些问题,本质上讲大家都没有区分清楚自己对于一个论点的逻辑认知情感认知。太多人喜欢用自己的感情认知去否定逻辑事实,以至于争来争去谁也没能说服谁,试图用唯心观点去解释唯物的现象或者相反,这是极其不正确的。

一个典型的问题是我不久之前在知乎上回答的一个问题:谁能大致说下vue和react的最大区别之处?我的回答简单总结就两句话:Vue 有一种设计层面追求的简洁感性之美,React 则是一种数学层面的逻辑一致之美。本质上讲没有什么好坏之分。但是诸如些类的一些前端框架问题正在变成一种帮派化的「站队」风气。

编程中的低阶(low-level)与高阶(hight-level)

注意这里讲的 low/hight level 并不是计算机术语中特定的某种形式。

有个笑话是这么讲的:

一个程序员去相亲,程序员自己介绍说「我是做嵌入式C语言底层开发的」,妹子反问「那啥时候做到高层开发呢」?

程序员们内心都有一个做底层开发的梦,因为这才是一个真正的程序员的追求与理想。

但是现实往往相反,大多数程序员每天都在写业务代码(重复的 CRUD)。所以很多程序员得出来一个结论就是:越底层的东西越重要,越高层的东西越肤浅。通常这也会行成一条鄙视链,他们会不由自主地忽略高层的东西。

注意这里有两个问题:

1. 业务代码有没有价值

当然有了,业务部分的代码是系统的最终结果。从结果导向上讲底层代码如何优雅、实现如何科学我们根本不关心,我们更关心整个系统层面的稳定与健壮。这是一种领导的高层次视角。

2. 底层的东西就一定重要

并不一定,这里说的不一定不是要完全否定底层的重要性。恰恰相反,软件领域一些特别优秀或者说伟大的软件底层并不是那么的如人意。比方说微软开发的 VSCode 代码编辑器。要是从底层去讨论它的构架合理性那确实挺像一个笑话的。因为本质上讲 VSCode 基于 electron,它把编辑器放在了一个 webview 中去运行,但是 webview 是用来浏览网页的,而且 electron 居然把 NodeJS 运行时也整合进去,以至于最小的一个应用解压完也有上百兆。

这感觉就像是上学的时候你很期待一个数学教授教你数学课,但是实际上你的数学课却是一个体育老师带的,这不是可不可以的问题对吧。

然而 VSCode 这样做的结果是:它还真的成功了,而且编辑器的性能比很多原生软件做的都要好,以至于周围所有人都在使用它,VSCode 在 Github 上名列前茅,也改变了很多程序员对于微软的刻板印象。

如果我们再回过头从哲学的角度去思考这个问题,实际上计算机中的底层高层正好对应着哲学中的理性感性

理性的认知是有对错可以批判的,但是感性的直观是没有对错的,因为即使是同一种声音、颜色对不同人观感都是不一样的。

比如说你在火车上看书,对面的人说话声音太大吵到了你。你说:你们说话小声点可以吗?对方会说:车上这么多人说话为啥就我们吵到你了?你说:因为就你们声音最大。对方说:我咋没觉得?

现实中的主观与客观

现实生活中我们经常会遇到一些对于论点的评价:主观还是客观。但是很多人没搞清楚这两个词的关系。多数人都觉得客观的观点就是好的,主观的就是臆断的。

主观和客观的关系就像是主人与客人的关系一样,有的人会认为应该主随客便,有的人则认为应该是客随主便。

任何语言中都有那么一些词语是成对儿出现的,像因果、主客、高低,这些词在被造出来的时候就是成对出现的,缺一不可。没有前者,后者将不会单独存在。它们之间没有绝对性的对于错。如果有,那对方就没有了存在的意义,反过来自己也将不存在。

当有人抛出一个观点的时候我的经验是一定要听清楚对方说的是「我觉得」还是「我认为」。「我觉得」那必然就是人家的主观感受,这种观点我们就没必要讨论了。你应该回复:「嗯,没错,确实是这样的。」。如果对方说「我认为」那你要是有不同的观点就完全可以和他讨论,因为说「我认为」的观点必然是有一些客观事实做为依据的,有事实有逻辑,那就可以有对错。

结语

哲学中的知识并不能完全解释现实中的事物,因为哲学研究的终点是一些没有结论的东西:上帝、自由、灵魂不朽。这些东西并非常人能理解的,但是人们对于无限真理的追求驱使着大家去研究它,很多人会觉得既然研究不出来结果那是不是就没意义了,当然不是。事实恰恰相反,如果我们把所有的事物本质都研究清楚了,那我们的存在也将失去意义。

我想要 AOP — 使用 AOP 分离关注点

1970-01-01 08:00:00

本文翻译自:I want my AOP

关注点表示人们的一种特殊的意愿、理念或是某个感兴趣的领域。从技术角度来讲:软件系统包括若干核心的系统级别的关注点。比方说:信用卡处理系统的核心关注点是处理交易,同时系统级别的关注点或许应该是处理日志、事务、一致性、授权、安全、性能等。许多这种关注点被叫做横切关注点 — 往往会影响许多模块的实现。

使用目前的编程方法,跨越多个模块横切关注点会导致系统更难设计、理解、实现和迭代。

阅读完全的「我想要 AOP」系列文章:

  1. 第一部分

  2. 第二部分

  3. 第三部分

面向切面的编程相比之前的方法更简单的分享了关注点,从而提供横切关注点的模块化。

在本系列文章中,第一篇涉及 AOP 的概念,我首先解释了在一般复杂的软件系统中由横切关注点引起的问题。然后,我引入了 AOP 核心概念,并展示了 AOP 是如何通过横切关注点解决问题的。

这个系列的第二篇文章将介绍 AspectJ,Xerox PARC 基于 Java 实现的 AOP 框架。最后一篇文章将以几个示例的方式向你展示 AOP 的概念,并基于建立更易懂、易实现、易迭代的软件系统。

软件编程方法的演进

早些年的计算机科学领域,开发者直接使用机器码进行编程。不幸的是,程序员花了更多时间去考虑特定机器的指令集而不是手头的问题。慢慢地,我们迁移到高级编程语言,高级编程语言允许对底层机器码进行一些抽象。然后结构化的语言出现了;我们现在可以根据任务的执行过程来分解我们的问题。然而,随着复杂度的增长,我们需要更好的技术。面向对象的编程让我们可以把系统看成一系列的合作对象。类可以让我们隐藏接口背后的实现细节。多态提供了通用行的为和接口,并允许更特殊的组件更改指定定行为,而无需接触基本概念的实现。

编程方法和语言定义了我们与机器交流的方式。每一种新方法都提供某种分解问题的方式:机器码、独立于机器的代码、过程、类等等。每种方法都在建立某种系统需求程序结构之间的对应关系。这些编程方法的演进让我们可以创建越来越复杂的系统。反过来复杂的系统使得我们又必须使用更先进的技术去解决这些复杂度。

目前来讲,放多新的软件项目开发都使用面向对象的编程模式。的确,面向对象的编程模式能模拟常见行为方面表现出了强大的能力。然而,我们很快将会看见,或许你已经有所体验了,面向对象的编程模式没能充分地解决许多跨区的行为的问题 — 那种通常不相关的模块。相比而言,面向切面的编程方法填补了这个空白。AOP 很可能代表了编程方法演进的下一个重要方向。

将系统看做一系列的关注点

我们可以将复杂系统看做是多个关注点的联合实现。典型的系统可能包含多种关注点,包括业务逻辑、性能、数据持久化 、日志,以及调试、授权、安全、线程安全 、错误检查等等。而且你还会遇到开发流程中的关注点,比如说:可理解、可维护,可追溯、更易迭代。图1描绘出了一个系统中不同模块关注点的实现。

system-layers图1

图2展示了一系列的需求(一个光束)通过关注点识别器(棱镜)分离各种关注点成为独立模块。这个过程就对应着我们开发过程的关注点。

prism图2

在系统中进行横切

开发者建立一个系统并且负责实现多个需求。我们可以把这些需求大体上从核心模块级别需求与系统级别需求两个维度进行分类。许多系统级别的需求相互之间(或与模块级别的需求)是正交的(相互依赖)。系统级别的需求倾向于横切许多核心模块,比如,一个个典型的企业应用包含的横切关注点有:身份验证,日志记录,资源池,管理,性能和存储管理。每个都被横切成多个子系统。比如,存储管理会影响每个业务对象。

让我们举个简单的例子,比如有一个单例实现封装了一些业务逻辑:

public class SomeBusinessClass extends OtherBusinessClass {
    // 核心数据成员
    // 其它数据成员:比如日志,数据一致性标识
    // 重写基类中的方法
    public void performSomeOperation(OperationInformation info) {
        // 保证授权正常
        // 保证条件正常满足
        // 锁定对象保证数据一致性
        // 线程进入threads access it
        // 保证缓存正常
        // 打印操作启动日志
        // ==== 进行具体的操作 ====
        // 打印操作完成日志
        // 解锁对象
    }
    // 与上面类似的其它操作
    public void save(PersitanceStorage ps) {
    }
    public void load(PersitanceStorage ps) {
    }
}

上面的代码中我们必须考虑至少三个问题,首先,其它数据成员不属于这个类所关心的内容。其次,performSomeOperation 的实现似乎比核心操作执行了更多的逻辑;它处理了日志、授权、线程安全以及其它外部关注点。重要的是,似乎这些许多外围关注点其它类也会用到。最后,save() 和 load() 方法操作存储层,这两个方法放在这个类中比较合适还是放在其它类中比较合适,这个问题并不是很清楚。

横切关注的问题

虽然会跨模块横切关注点,但是现在的技术实现倾向于使用一维的方式实现,把问题聚焦在需求与实现的单一维度。这个单一维度的实现将变成核心模块级别的实现。其余的需求围绕着这个主导维度被分类。换句话说,需求空间是多维的,然而实现空间是单维的。这种不匹配会导致需求与实现之间的映射难以做到。

症状

使用目前的方法实现横切关注点会出现一些问题/症状,大体上分两类:

暗示

代码纠缠与代码分散对软件设计和开发有以下影响:

目前的解决方式

由于大多数系统都可以横切关注点,因此出现模块化实现的一些技术就不足为奇了。这些技术包括混入(mix-in)类,设计模式和领域特定的解决方案。

使用混入类可以让你延迟分离关注点到最终的实现。主类包含混入类实例,并允许系统的其他部分设置该实例。例如,上面的信用卡处理例子,将一个实现了业务逻辑的类组合成混入类,系统的其它模块可以通过配置来获取适合自身的日志器。例如,日志器可以设置成使用文件系统或者消息中间件。发送日志的被延后了,但是各个消息发送点(调用的地方)还是需要加入相关的代码。

基于行为的设计模式,比如说访问者、模板方法,可以让你延迟实现。但是就像混入类一样,控制操作—调用访问逻辑或者模板方法—仍然在主类中。

领域特定的解决方案,比如说框架和应用服务,让开发者可以用模块化的方式实现横切关注点。比如 EJB 架构,在安全、管理、性能和持久容器管理方面实现横切关注点。Bean 的开发者专注于业务逻辑,部署工程师专注于部署相关问题,比如 bean-data 与数据库的对应关系。对于 Bean 开发者来讲其余需要关注的就只有存储的问题了。在这个例子中你可以使用基于 XML 的映射描述符来实现横切关注点。

领域特定的解决方案提供了一种特殊的办法来解决指定的问题。它的缺点是,开发者必须为它学习新的技术。然后由于这些解决方案都是领域特定的,它并不能直接有效地横切关注点。

构架设计的窘境

好的系统架构会考眼前与未来的一些需求,从而避免打补丁式的实现。但是这有一个问题,预测未来是一件非常困难的事情。如果你没有搞清楚未来的需求,那就需要改变、或者将系统的很多地方重新实现。另外一方面,将精力聚焦在低可能性的一些需求会导致过度的设计、混乱和臃肿的系统。因此系统构架的一个困境是:应该设计到什么程度?我应该保守式的设计还是盈余式的设计。

比方说,构架中是否应该追念一个初始化时并不需要的日志系统?如果是,日志打点的地方应该在哪里,什么样的信息应该被记录?这个是一个类似的出现在优化相关需求过程中的困境—我们很少提前知道瓶颈,常归的做法是构建一个系统,对其进行分析,并通过优化进行改进以提高性能。这种方法会潜在引导我们根据分析结果去修改系统很多部分。过不了多久,一个新的瓶颈又会出现,而这个瓶颈很可能就是上一步的改进引起的。设计可复用库架构的任务会变得非常困难,因为找到库的所有的使用场景并非易事。

总之,架构师很少知道系统所有可能需要解决的问题。即使提前了解了需求,一个实现的具体细节可能并没有被考虑到。因此,架构师面临着究竟应该保守设计还是盈余设计的困境。

AOP 的基本概念

到这里我们主要讨论了模块化的横切关注点会有很大益处。研究人员已经研究了在「关注点分离」这一更为泛化的主题下完成该任务的各种方法。 AOP 就是这样的一种方法。AOP 力争将关注点彻底分离,以克服上述问题。

AOP 的核心在于,以松散耦合的方式让你实现一个独立的关注点,然后结合这些实现成为一个最终的系统。确实,AOP 使用松散耦合、模块化的分离关注点的方式来创建系统。相反,OOP,则使用松散耦合、模块化的实现共同关注点方式来创建系统。AOP 中模块化的单位叫做横切面(aspect),好比 OOP 中共同的关注点是(class)。

AOP涉及三个不同的开发步骤:

  1. 切面分解:将需求分解并识别出横切关注点与共同关注点。你可以将系统级别的关注点与模块级别关注点分离。比如说,上面提到的信用卡模块,你需要识别三种关注点:信用卡核心流程,日志和授权。

  2. 关注点实现:分离的实现各个关注点。像上面的例子一样,你可以单独实现核心流程、日志和授权三个单元。

  3. 切面重组:在这个步骤中,切面集成器通过创建模块化单元来指定重组规则 — 切面。重组过程(也称为编织或集成)使用此信息来组合成最终系统。比如上面的信用卡例子,你得使用一种 AOP 实现的语言具体/规范化操作中哪一步需要打日志。还得指定每个操作在被前都需要清除授权。

weaver

AOP 实现横切关注点的方法与 OOP 不一样。对于 AOP 来讲,每个关注点的实现并不会意识到其它关注点下在横切它。比如上面的信用卡例子,信用止处理模块并不知道其它的关注点是日志、授权操作。这对于 OOP 来讲意味着很大的范式转换。

注意:一个 AOP 的实现可以采用其它编程方法作为它的基本方法。因此可以保证基础系统非常完善。比如说,一个 AOP 的实现可以选择 OOP 做为基础系统,这样就可以获得 OOP 共同关注点的优势。每个独立的关注点可以采用 OOP 技术识别关注点。这类似于过程式的语言可以做为许多 OOP 语言的基础语言。

编织的例子

编织器是一个将独立的关注点纺织起来的过程。换句话说,编织器根据提供给它的某些标准将不同的执行逻辑片段编织起来。

为了能够演示编织过程,让我们回到之前的信用卡处理系统的例子。为了看起来更简单,我们只考虑两个操作:信用卡和借记卡。并且已经有一个合适的日志器了。

考虑下面的信用卡处理模块:

public class CreditCardProcessor {
    public void debit(CreditCard card, Currency amount) 
       throws InvalidCardException, NotEnoughAmountException,
              CardExpiredException {
        // Debiting logic
    }
    
    public void credit(CreditCard card, Currency amount) 
        throws InvalidCardException {
        // Crediting logic
    }
}

同样还有一个日志接口:

public interface Logger {
    public void log(String message);
}

我们想要的组合需要以下编织规则,这些规则以自然语言表示(稍后将提供这些编织规则的编程语言版本):

  1. 打印每个公共操作的开始

  2. 打印每个公共操作完成

  3. 打印每个公共操作的异常

编织器随后将使用这些规则,并关注每个实现以产生等价于以下代码的效果。

public class CreditCardProcessorWithLogging {
    Logger _logger;
    public void debit(CreditCard card, Money amount) 
        throws InvalidCardException, NotEnoughAmountException,
               CardExpiredException {
        _logger.log("Starting CreditCardProcessor.credit(CreditCard,
Money) "
                    + "Card: " + card + " Amount: " + amount);
        // Debiting logic
        _logger.log("Completing CreditCardProcessor.credit(CreditCard,
Money) "
                    + "Card: " + card + " Amount: " + amount);
    }
    
    public void credit(CreditCard card, Money amount) 
        throws InvalidCardException {
        System.out.println("Debiting");
        _logger.log("Starting CreditCardProcessor.debit(CreditCard,
Money) "
                    + "Card: " + card + " Amount: " + amount);
        // Crediting logic
        _logger.log("Completing CreditCardProcessor.credit(CreditCard,
Money) "
                    + "Card: " + card + " Amount: " + amount);
    }
}

分解 AOP 语言

就像其它编程语言方法的实现,AOP 实现包括两个部分:一种语言规范和一种实现。语言规范描述语言的构成与语法。实现则根据语言规范去论证代码的正确性,然后转换成机器码然后执行。在这小节中,我将解释 AOP 语言的不同组成部分。

AOP 语言的规范

在一个高层次上,AOP 语言有两种组件:

AOP 语言的实现

AOP 语言编译器有以下两个逻辑步骤:

  1. 结合独立的关注点

  2. 转换最终结果成可执行代码

AOP 语言实现编织器的方法有很多,包括源码到源码的翻译。你可以预处理独立切面的源码,然后将它加工成编织过的源码。然后 AOP 编译器将这些源码转交给基本语言编译器用来生成最终可执行代码,最后让 Java 编译器把代码编译成子节码。同样的,编织过程可以是子节码级别的;毕竟,子节码也是一种源代码。引外底层系统—VM虚拟机,是可以感知到切面的。使用这种基于 Java 的 AOP 实现,比如,VM虚拟机将首先加载编织规则,然后将这些规则应用到随后加载的类中。换句话说,它表现得像是 JIT 化的切面编织。

AOP 的益处

AOP 有助于克服由代码纠缠和代码分散引起的上述问题。以下是 AOP 提供的其他优势:

AspectJ:一种 Java 的 AOP 实现

Nestjs 框架教程(第十篇:拦截器)

1970-01-01 08:00:00

nestjs-inteceptors

拦截器(Interceptors)是一个使用 @Injectable() 装饰的类,它必须实现 NestInterceptor 接口。

拦截器有一系列的功能,这些功能的设计灵感都来自于面向切面的编程(AOP)技术。这使得下面这些功能成为可能:

基础

每个拦截器都要实现 intercept() 方法,此方法有两个参数。第一个是 ExecutionContext 实例(这和守卫中的对象一样)。ExecutionContext 继承自 ArgumentsHost。上一节中我们见过,它是一个包装了传递向原始处理器而且根据应用的不同包含不同的参数数组的类

执行上下文

ExecutionContext 通过继承 ArgumentsHost,提供了更多的执行过种中的更多细节,它看起来长这样:

export interface ExecutionContext extends ArgumentsHost {
  getClass<T = any>(): Type<T>;
  getHandler(): Function;
}

getHandler() 方法返回一个将会被调用的路由处理器的引用。getClass() 方法返回控制器类的类型。例如,如果当前进行着一个 POST 请求,假定它会由 CatsController 的 create() 方法处理,那么 getHandler() 方法将返回 create() 方法的引用,而 getClass() 则会返回 CatsController 的类型(非实例)

调用处理器

第二个参数是一个 CallHandler。CallHandler 接口实现了 handle() 方法,这个方法就是你可以在你拦截器的某个地方调用的路由处理器。如果你的 intercept() 方法中没调用 handle() 方法,那么路由处理器将不会被执行。

不像守卫与过滤器,拦截器对于一次请求响应有完全的控制权与责任。这样的方式意味着 intercept() 方法可以高效地包装请求/响应流。因此,你可以在最终的路由处理器执行前/后实现自己的逻辑。显然,你已经可以通过在 intercept() 方法中的 handle() 调用之前写自己的代码,但是后续的逻辑应该如何处理?因为 handle() 方法返回的是一个 Observable,我们可以使用 RxJS 做到修改后来的响应。使用 AOP 技术,路由处理器的调用被称做一个 切点(Pointcut),这表示一个我们的自定义的逻辑插入的地方。

假如有一个 POST /cats 的请求,这个请求将被 CatsController 中的 create() 方法处理。如果一个没调用 handle() 方法的拦截器在某处被调用,create() 方法将不会被执行。一但 handle() 方法被调用(它的 Observable 已返回),create() 处理器将被触发。一但响应流通过 Observable 接收到,附加的操作可以在注上被执行,最后的结果将返回给调用方。

切面拦截

我们将要研究的第一个例子就是用户登录的交互。下面展示了一个简单的日志拦截器:

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Before...');

    const now = Date.now();
    return next
      .handle()
      .pipe(
        tap(() => console.log(`After... ${Date.now() - now}ms`)),
      );
  }
}

由于 handle() 方法返回了一个 RxJS 的 Observable 对象,对于修改流我们将有更多的选择。上面的示例中我们使用了 tap() 操作符。它在 Observable 流的正常或异常终止时调用我们的匿名日志记录函数,但不会干扰到响应周期。

绑定拦截器

我们可以使用 @UseInterceptors() 装饰器来绑定一个拦截器,和管道、守卫一样,它即可以是控制器作用域的,也可以是方法作用域的,或者是全局的。

@UseInterceptors(LoggingInterceptor)
export class CatsController {}

上面的实现,在请求进入 CatsController 后,你将看到下面的日志输出。

Before...
After... 1ms

响应映射

我们已经知道了 handle() 方法返回一个 Observable。流包含路由处理器返回的值,因此,我们可以很容易的使用 RxJS 的 map() 操作符改变它。

注意:响应映射功能并不适用于库级别的响应策略(不可以使用 @Res 装饰器)

让我们新建一个 TransformInterceptor,它可以修改每个响应。它将使用 map() 操作符来给响应对象符加 data 属性,并且将这个新的响应返回给客户端。

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
  data: T;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    return next.handle().pipe(map(data => ({ data })));
  }
}

当有请求进入时,响应看起来将会是下面这样:

{
  "data": []
}

拦截器对于创建整个应用层面的可复用方案有非常大的意义。比如说,我们需要将所有响应中出现的 null 值改成空字符串 ""。我们可以使用拦截器功能仅用下面一行代码就可以实现

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class ExcludeNullInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next
      .handle()
      .pipe(map(value => value === null ? '' : value ));
  }
}

异常映射

另外一个有趣的用例是使用 RxJS 的 catchError() 操作符来重写异常捕获:

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  BadGatewayException,
  CallHandler,
} from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ErrorsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next
      .handle()
      .pipe(
        catchError(err => throwError(new BadGatewayException())),
      );
  }
}

流重写

有一些情况下我们希望完全阻止处理器的调用并返回一个不同的值。比如缓存的实现。让我们来试试使用缓存拦截器来实现它。当然真正的缓存实现还包含 TTL,缓存验证,缓存大小等问题,我们这个例子只是一个简单的示意。

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const isCached = true;
    if (isCached) {
      return of([]);
    }
    return next.handle();
  }
}

上面的代码中我们硬编码了 isCached 变量,以及返回的缓存数据 []。关键点在于我们返回了一个新的流,使用了 RxJS 的 of() 操作符。因此路由处理器永远不会被调用。为了实现一个更完整的解决方案,你可以通过使用 Reflector 创建一个自定义的装饰器来实现缓存功能。

更多的操作符

RxJS 的操作符有很多种能力,我们可以考虑下面这种用例。你需要处理路由请求的超时问题。当你的响应很久都没正常返回时,你会想把它关闭并返回一个错误的响应。

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { timeout } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(timeout(5000))
  }
}

5 秒后,请求处理将会被取消。

Nestjs 框架教程(第九篇:守卫)

1970-01-01 08:00:00

守卫(Guards)是一个使用 @Injectable() 装饰的类,它必须实现 CanActivate 接口。

nestjs-guards

守卫只有一个职责,就是决定请求是否需要被控制器处理。一般用在权限、角色的场景中。

守卫和中间件的区别在于:中间件很简单,next 方法调用后中间的任务就完成了。但是守卫需要关心上下游,它需要鉴别请求与控制器之间的关系。

守卫会在中间件逻辑之==后==、拦截器/管道之==前==执行。

授权守卫

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}

canActivate 返回 true,控制器正常执行,false 请求会被 deny

执行上下文

ExecutionContext 不但继承了 ArgumentsHost,还有两个额外方法:

export interface ExecutionContext extends ArgumentsHost {
  getClass<T = any>(): Type<T>;
  getHandler(): Function;
}

getHandler() 方法会返回一个将被调用的方法处理器,getClass() 返回处理器对应的控制器类。

基于角色的认证

我们来实现一个小型的基于角色的认证系统。

创建一个守卫,先让它返回 true,后面再改:

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}

绑定守卫

就像过滤器一样,守卫可以是控制器作用域的,也可以是方法作用域或者全局作用域。我们使用 @UseGuards 来引用一个控制器作用域的守卫。

@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}

如果想引用到全局作用域可以调用 useGlobalGuards 方法。

const app = await NestFactory.create(ApplicationModule);
app.useGlobalGuards(new RolesGuard());

由于我们在根模块外层引用了全局守卫,这时守卫无法注入依赖。所以我们还需要在要模块上引入。

import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ],
})
export class ApplicationModule {}

反射

虽然现在已经有了守卫,但是它还没有执行上下文。CatsController 应该有一些需要访问到的权限类型。比如:管理员(admin)角色可以访问、其它角色不可以。

这时我们需要对控制器(或方法)添加一些元数据,用来标记这个控制器的权限类型。在 Nest 中我们通常使用 @SetMetadata() 装饰器来完成这个工作。

@Post()
@SetMetadata('roles', ['admin'])
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

上面的代码表示给 create 方法设置角色的元数据,用来标识 create 方法只能是 roles 关联的一些角色(admin…)才能访问到的。

如果你觉得 SetMetadata 这个装饰器看着不是那么见名知意,也可以实现一个自定义的装饰器。

import { SetMetadata } from '@nestjs/common';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

这样就可以用更简洁的方式来声明角色权限关系了:

@Post()
@Roles('admin')
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

联合在一起使用

我们将使用反射机制来获取控制器上的元数据。

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    if (!roles) {
      return true;
    }
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    const hasRole = () => user.roles.some((role) => roles.includes(role));
    return user && user.roles && hasRole();
  }
}

当 canActivate 方法返回 false 时,Nest 将会抛出一个 ForbiddenException 异常。你也可以手动抛出别的异常:

throw new UnauthorizedException();

Nestjs 框架教程(第八篇:管道)

1970-01-01 08:00:00

nestjs-pipe

管道(Pipes)是一个用 @Injectable() 装饰过的类,它必须实现 PipeTransform 接口。

从官方的示意图中我们可以看出来管道 pipe 和过滤器 filter 之间的关系:管道偏向于服务端控制器逻辑,过滤器则更适合用客户端逻辑。

过滤器在客户端发送请求**==后==处理,管道则在控制器接收请求==前==**处理。

管道通常有两种作用:

管道会处理控制器路由的参数,Nest 会在方法调用前插入管道,管道接收发往该方法的参数,此时就会触发上面两种情况。然后路由处理器会接收转换过的参数数据并处理后续逻辑。

++小提示++:管道会在异常范围内执行,这表示异常处理层可以处理管道异常。如果管道发生了异常,控制器的执行将会停止

内置管道

Nest 内置了两种管道:ValidationPipeParseIntPipe

import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';

@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}

注意这里可能不太好理解,因为我们前面已经在控制器参数上使用了 @body 装饰器,并且使用 TypeScript 的类型声明它为 CreateCatDto,如下:

async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

但是 TypeScript 类型是静态的、编译时类型,当编译成 JavaScript 后在运行时并没有任何类型校验。这时我们就需要自己去验证,或者借助第三方工具、库来验证。

Nest 官方文档在这一节中使用了 joi 这个验证库。这个验证库的使用需要传入一个 schema,实际上对应着我们的在 Nest 中写的 dto 类型,所以我们只需要给 joi 传入一个 CreateCatDto 类的实例即可。

首页在 ValidationPipe 管道中添加 joi 的验证功能。验证通过就返回,不通过直接抛出异常:

@Injectable()
export class JoiValidationPipe implements PipeTransform {
  constructor(private readonly schema: Object) {}

  transform(value: any, metadata: ArgumentMetadata) {
    const { error } = Joi.validate(value, this.schema);
    if (error) {
      throw new BadRequestException(SON.stringify(error.details));
    }
    return value;
  }
}

绑定管道

管道有了,我们还需要在控制器方法上绑定它。

@Post()
@UsePipes(new JoiValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

使用 @UsePipes 修饰器即可,传入管道的实例,并构造 schema。此时我们的应用就可以在运行时通过 schema 去校验参数对象的开头了。createCatSchema 的写法可以参考相关文档

const createCatSchema = {
  name: Joi.string().required(),
  age: Joi.number().required(),
  breed: Joi.string().required(),
}

例如上面的 schema,如果客户端发送的 POST 请求中如果缺少任意参数 Nest 都会捕获到这个异常并返回信息:

{
    "statusCode": 400,
    "error": "Bad Request",
    "message": "[{\"message\":\"\\\"name\\\" is required\",\"path\":[\"name\"],\"type\":\"any.required\",\"context\":{\"key\":\"name\",\"label\":\"name\"}}]"
}

注意 message 就是我们在管道中传到异常类 BadRequestException 中的参数。

类验证器

当然上面这种方法看起来没那么优雅,因为毕竟 CreateCatDto 和 createCatSchema 太重复了。Nest 还支持类型验证器,虽然也需要借助于三方库,但是看起来会优雅很多。

首先,要使用类验证器,你需要先安装 class-validator 库。

npm i --save class-validator class-transformer

class-validator 可以让你使用给类变量加装饰器的写法给类添加额外的验证功能。这样以来我们就可以直接在原始的 CreateCatDto 类上添加验证装饰器了,这样看起来就整洁多了,而且还没有重复代码:

import { IsString, IsInt } from 'class-validator';

export class CreateCatDto {
  @IsString()
  readonly name: string;

  @IsInt()
  readonly age: number;

  @IsString()
  readonly breed: string;
}

不过管道验证器中的代码也需要适配一下:

import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

注意这次的 transform 是 async 异步的,因为内部需要用到异步验证方法。Nest 是支持你这么做的,因为管道可以是异步的。

然后我们可以插入这个管道,位置可以是方法级别的,也可以是参数级别的。

++参数作用域++

@Post()
async create(
  @Body(new ValidationPipe()) createCatDto: CreateCatDto,
) {
  this.catsService.create(createCatDto);
}

++方法作用域++

@Post()
@UsePipes(new ValidationPipe())
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

管道修饰器入参可以是类而不必是管道实例:

@Post()
@UsePipes(ValidationPipe)
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

这样以来将实例化过程留给框架去做并肝启用依赖注入。

由于 ValidationPipe 被尽可能的泛化,所以它可以直接使用在全局作用域上。

async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

转换用例

我们还可以用管道来进行数据转换,比如说上面的例子中 age 虽然声明的是 int 类型,但是我们知道 HTTP 请求传递的都是纯字符流,所以通常我们还要把期望传进行类型转换。

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}

上面这个管道的功能就是强制转换成 Int 类型,如果转换不成功就抛出异常。我们可以针对性的对传入控制器的某个参数插入这个管道:

@Get(':id')
async findOne(@Param('id', new ParseIntPipe()) id) {
  return await this.catsService.findOne(id);
}

内置的验证管道

比较贴心的是 Nest 已经内置了如上面的例子类似的一些通用验证器,你可以以参数的方式去实例化 ValidationPipe。

@Post()
@UsePipes(new ValidationPipe({ transform: true }))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

ValidationPipe 接收一个 ValidationPipeOptions 类型的参数,并且这个参数继承自 ValidatorOptions

export interface ValidationPipeOptions extends ValidatorOptions {
  transform?: boolean;
  disableErrorMessages?: boolean;
  exceptionFactory?: (errors: ValidationError[]) => any;
}

ValidatorOptions 又继承了如下所有 class-validator 的参数:

Option Type Description
skipMissingProperties boolean If set to true, validator will skip validation of all properties that are missing in the validating object.
whitelist boolean If set to true, validator will strip validated (returned) object of any properties that do not use any validation decorators.
forbidNonWhitelisted boolean If set to true, instead of stripping non-whitelisted properties validator will throw an exception.
forbidUnknownValues boolean If set to true, attempts to validate unknown objects fail immediately.
disableErrorMessages boolean If set to true, validation errors will not be returned to the client.
exceptionFactory Function Takes an array of the validation errors and returns an exception object to be thrown.
groups string[] Groups to be used during validation of the object.
dismissDefaultMessages boolean If set to true, the validation will not use default messages. Error message always will be undefined if its not explicitly set.
validationError.target boolean Indicates if target should be exposed in ValidationError
validationError.value boolean Indicates if validated value should be exposed in ValidationError.

Nestjs 框架教程(第七篇:异常过滤器)

1970-01-01 08:00:00

Nest 框架内部实现了一个异常处理层,专门用来负责应用程序中未处理的异常。

nestjs-filter

默认情况未处理的异常会被全局过滤异常器 HttpException 或者它的子类处理。如果一个未识别的异常(非 HttpException 或未继承自 HttpException)被抛出,下面的信息将被返回给客户端:

{
  "statusCode": 500,
  "message": "Internal server error"
}

基础异常

我们可以从控制器的方法中手动抛出一个异常:

@Get()
async findAll() {
  throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}

客户端将收到如下信息:

{
  "statusCode": 403,
  "message": "Forbidden"
}

当然你也可以自定义返回状态值和错误信息:

@Get()
async findAll() {
  throw new HttpException({
    status: HttpStatus.FORBIDDEN,
    error: 'This is a custom message',
  }, 403);
}

异常的级别

比较好的做法是实现你自己想要的异常类。

export class ForbiddenException extends HttpException {
  constructor() {
    super('Forbidden', HttpStatus.FORBIDDEN);
  }
}

然后你就可以手动在需要的地方抛出它。

@Get()
async findAll() {
  throw new ForbiddenException();
}

HTTP 异常

Nest 内置了以下集成自 HttpException 的异常类:

异常过滤器

如果你想给异常返回值加一些动态的参数,可以使用异常过滤器来实现。例如下面的异常过滤器将会给 HttpException 添加额外的时间缀和路径参数:

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

注意:所有的异常过滤器都必须实现泛型接口 ExceptionFilter。就是说你必须要提供一个 catch(exception: T, host: ArgumentsHost) 方法

参数宿主

上面代码中的 host 参数是一个类型为 ArgumentsHost 的原生请求处理器包装对象。根据应用程序的不同它具有不同的接口。

export interface ArgumentsHost {
  getArgs<T extends Array<any> = any[]>(): T;
  getArgByIndex<T = any>(index: number): T;
  switchToRpc(): RpcArgumentsHost;
  switchToHttp(): HttpArgumentsHost;
  switchToWs(): WsArgumentsHost;
}

绑定过滤器

可以使用 @UseFilters 装饰器让一个控制器方法具有过滤器处理逻辑。

@Post()
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

当然过滤器可以被使用在不同的作用域上:方法作用域、控制器作用域、全局作用域。比如应用一个控制器作用域的过滤器,可以这么写:

@UseFilters(new HttpExceptionFilter())
export class CatsController {}

全局过滤器可以通过如下代码实现:

async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();

不过这样注册的全局过滤器无法进入依赖注入,因为它在模块作用域之外。为了解决这个问题,你可以在根模块上面注册一个全局作用域的过滤器。

import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: HttpExceptionFilter,
    },
  ],
})
export class ApplicationModule {}

捕获所有异常

@Catch() 装饰器不传入参数就默认捕获所有的异常:

import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    const status = exception instanceof HttpException
      ? exception.getStatus()
      : HttpStatus.INTERNAL_SERVER_ERROR;

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

继承

通常你可能并不需要自己实现完全定制化的异常过滤器,可以继承自 BaseExceptionFilter 即可复用内置的过滤器逻辑。

import { Catch, ArgumentsHost } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';

@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    super.catch(exception, host);
  }
}

Nestjs 框架教程(第六篇:中间件)

1970-01-01 08:00:00

中间件就是一个函数,在路由处理器之前调用。这就表示中间件函数可以访问到请求和响应对象以及应用的请求响应周期中的 next() 中间间函数。

nestjs-middleware

Nest 中间件实际上和 Express 的中间件是一样的,Express 文档中对中间件的描述如下:

中间件函数主要做以下的事情:

  • 执行任意的代码

  • 对请求/响应做操作

  • 终结请求-响应周期

  • 调用下一个栈中的中间件函数

  • 如果当前的中间间函数没有终结请求响应周期,那么它必须调用 next() 方法将控制权传递给下一个中间件函数。否则请求将被挂起

Nest 允许你使用函数或者类来实现自己的中间件。如果用类实现,则需要使用 @Injectable() 装饰,并且实现 NestMiddleware 接口。

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: Function) {
    console.log('Request...');
    next();
  }
}

依赖注入

中间件也是支持依赖注入的,就像其它支持方式一样,你可以使用构造函数注入依赖。

应用中间件

@Module() 装饰器中并不能指定中间件参数,我们可以在模块类的构 configure() 方法中应用中间件,下面的代码会应用一个 ApplicationModule级别的日志中间件 LoggerMiddleware

@Module({
  imports: [CatsModule],
})
export class ApplicationModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}

上面的代码 forRoutes 方法表示只将中间件应用在 cats 路由上,还可以是指定的 HTTP 方法,甚至是路由通配符:

.forRoutes({ path: 'cats', method: RequestMethod.GET });
.forRoutes({ path: 'ab*cd', method: RequestMethod.ALL });

当然,你也可以指定不包括某些路由规则:

consumer
  .apply(LoggerMiddleware)
  .exclude(
    { path: 'cats', method: RequestMethod.GET },
    { path: 'cats', method: RequestMethod.POST }
  )
  .forRoutes(CatsController);

不过请注意 exclude 方法不能运用在函数式的中间件上,而且这里指定的 path 也不支持通配符,这只是个快捷方法,如果你真的需要某种路由级别的控制,那完全可以把逻辑写在一个单独的中间件中。

函数式的中间件

函数式的中间件可以用一个简单无依赖函数来实现:

export function logger(req, res, next) {
  console.log(`Request...`);
  next();
};

多个中间件

apply 方法传入多个中间件参数即可:

consumer.apply(cors(), helmet(), logger)
.forRoutes(CatsController);

全局中间件

在实现了 INestApplication 接口的实例上调用 use() 方法即可:

const app = await NestFactory.create(ApplicationModule);
app.use(logger);
await app.listen(3000);

Nestjs 框架教程(第五篇:模块)

1970-01-01 08:00:00

模块(Module)是一个使用了 @Module() 装饰的类。@Module() 装饰器提供了一些 Nest 需要使用的元数据,用来组织应用程序的结构。

nestjs-module

每个应用都至少有一个根模块,根模块就是 Nest 应用的入口。Nest 会从这里查找出整个应用的依赖/调用

@Module() 装饰器接收一个参数对象,有以下取值:

| providers | 可以被 Nest 的注入器初始化的 providers,至少会在此模块中共享 | | controllers | 这个模块需要用到的控制器集合 | | imports | 引入的其它模块集合 | | exports | 此模块提供的 providers 的子集,其它模块引入此模块时可用 |

模块默认会封装 providers,如果要在不同模块之间共享 provider 可以在 exports 参数中指定。

功能模块

使用下面的代码可以将相关的控制器和 Service 包装成一个模块:

import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

++小提示++:也可以使用 CLI 来自动生成模块:$ nest g module cats

这样我们就完成了一个模块的封装。

共享的模块

在 Nest 中模块默认是单例的,因此你可在不同的模块之间共享任意 Provider 实例。

nestjs-shared-module

模块都是共享的,我们可以通过导出当前模块的指定 Service 来实现其它模块对 Service 的复用。

import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService] // 导出
})
export class CatsModule {} 

模块的重复导出

给模块包装一层即可实现:

@Module({
  imports: [CommonModule],
  exports: [CommonModule],
})
export class CoreModule {}

依赖注入

模块的构造函数中也可以注入指定的 providers,通常用在一些配置参数场景。

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {
  constructor(private readonly catsService: CatsService) {}
}

但是模块类本身并不可以装饰成 provider,因为这会造成循环依赖

全局模块

当一些模块在你的应用频繁使用时,可以使用全局模块来避免每次都要调用的问题。Angular 会把 provider 注册到全局作用域上,然而 Nest 会默认将 provider 注册到模块作用域上。如果你没有显示的导出模块的 provider,那么其它地方就无法使用它。

如果你想让一个模块随处可见,那就使用 @Global() 装饰器来装饰这个模块。

@Global()
@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService],
})
export class CatsModule {}

@Global() 装饰器可以让模块获得全局作用域

动态模块

Nest 模块系统支持动态模块的功能,这将让自定义模块的开发变得容易。

import { Module, DynamicModule } from '@nestjs/common';
import { createDatabaseProviders } from './database.providers';
import { Connection } from './connection.provider';

@Module({
  providers: [Connection],
})
export class DatabaseModule {
  static forRoot(entities = [], options?): DynamicModule {
    const providers = createDatabaseProviders(options, entities);
    return {
      module: DatabaseModule,
      providers: providers,
      exports: providers,
    };
  }
}

模块的静态方法 forRoot 返回一个动态模块,可以是同步或者异步模块。

Nestjs 框架教程(第四篇:Providers)

1970-01-01 08:00:00

Provider 主要的设计理念来自于控制反转(Inversion of Control,简称 IOC[1] )模式中的依赖注入(Dependency Injection)特性。使用 @Injectable() 装饰的类就是一个 Provider,装饰器方法会优先于类被解析执行。

到这里我们应该要了解整个 Nest 框架的三层结构,Nest 和传统的 MVC 框架的区别在于它更注重于后端部分(控制器、服务与数据)的架构,视图层相对比较独立,完全可以由用户自定义配置。

nestjs-framework-compare

Nest 的分层借鉴自 Spring,更细化。随着代码库的增长 MVC 模式中 Modal 和 Controller 会变得含糊不清,导致难于维护。

Services

我们可以自己实现一个名叫 CatsService 的 Service

export interface Cat {
  name: string;
  age: number;
  breed: string;
}
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }
}

++小提示++:也可以使用 CLI 工具自动生成一个 Service $ nest g service cats

有了 Service 我们就可以在控制器中注入并引用到它了

@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}
  // 等同于
  private readonly catsService: CatsService
  constructor(catsService: CatsService) {
    this.catsService = catsService
  }

  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }
}

依赖注入的很多种方法,Nest 使用了构建函数注入的方式,看起来非常直观。这个时候我们就可以发现 Nest 的优点了,至少你能发现 Controller 和 Service 处于完全解耦的状态:Controller 做的事情仅仅是接收请求,并在合适的时候调用到 Service,至于 Service 内部怎么实现的 Controller 完全不在乎。

这样以来有两个好处:其一,Controller 和 Service 的职责边界很清晰,不存在灰色地带;其二,各自只关注自身职责涉及的功能,比方说 Service 通常来写业务逻辑,但它也仅仅只与业务相关。当然你可能会觉得这很理想,时间长了增加了诸如缓存、验证等逻辑后,代码最终会变得无比庞大而难于维护。事实上这也是一个框架应该考虑和抽象出来的,后续 Nest 会有一系列的解决方法,但目前为至我们只需要了解到 Controller 和 Service 的设计原理即可。

依赖注入

constructor(private readonly catsService: CatsService) {}

得益于 TypeScript 类型,Nest 可以通过 CatsService 类型查找到 catsService,依赖被查找并传入到控制器的构造函数中。

通常我们在没有依赖注入的时候如果 A 依赖于 B,那么在 A 初始化或者执行中的某个过程需要先创建 B,这时我们就认为 A 对 B 的依赖是正向的。但是这样解决依赖的办法会得得 A 与 B 的逻辑耦合在一起,依赖越来越多代码就会变的越来越糟糕。如下图所示,齿轮之间是相互依赖的,一损俱损。

DI

控制反转(IOC)模式就是要解决这个问题,它会多引入一个容器(Container)的概念,让一个 IOC 容器去管理 A、B 的依赖并初始化。

DI-IOC

当我们去掉容器时,剩下的齿轮成了一个个独立的功能模块。

DI_IOC

注入作用域

Providers 有一个和应用程序一样的生命周期。当应用启动,每个依赖都必须被获取到。将会有单独的一章来讲解注入作用域

自定义的 Providers

Nest 有一个内置的 IOC 容器,用来解析 Providers 之间的关系。这个功能相对于 DI 来讲更底层,但是功能却异常强大,@Injectable() 只是冰山一角。事实上,你可以使用值,类和同步或者异步的工厂。

可选的 Providers

有时候,你可以会需要一个依赖,但是这个依赖并不需要一定被容器解析出来。比如我们通常会传入一个配置对象,但是如果不传会使用一个默认值代替。可以使用 @Optional() 来装饰一个非必选的参数。

@Injectable()
export class HttpService<T> {
  constructor(
    @Optional() 
    @Inject('HTTP_OPTIONS') 
    private readonly httpClient: T
  ) {}
}

基于属性的注入

前面我们提过了 Nest 实现注入是基于类的构造函数的,但是在一些特殊情况下,基于属性的注入会特别有用。

比如一个顶层的类依赖一个或多个 Providers 时,通过在子类的构造函数中调用 super() 方法并不是很优雅,为了避免这种情况我们可以在属性上使用 @Inject() 装饰器。

@Injectable()
export class HttpService<T> {
  @Inject('HTTP_OPTIONS')
  private readonly httpClient: T;
}

++警告++:如果你的类并没有继承其它 Provider,那么一定要使用基于构造函数注入方式

注册 Provider

一般来讲控制器就是 Service 的消费(使用)者,我们需要将这些 Service 注册到 Nest 上,这样就可以让 Nest 帮你完成注入操作。通常我们会使用 @Module 装饰器来完成注册的过程。

import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';
import { CatsService } from './cats/cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class ApplicationModule {}


  1. 控制反转 ↩︎

Nestjs 框架教程(第三篇:控制器)

1970-01-01 08:00:00

控制器(Controller)负责处理客户端请求并发送响应内容,在传统的 MVC 架构中控制器就是负责处理指定请求与应用程序的对应关系,路由则决定具体处理哪个请求。

nestjs-controller

路由

得益于 TypeScript,在 Nest 中我们可以使用类来实现控制器的功能,使用装饰器来实现路由功能。它们分别需要配合 @Controller 和 @Get 饰器来使用,前者是控制器类的装饰,后者是具体方法的装饰器。

比如下面的代码:

import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}

上面的代码声明了一个猫咪控制器类,实现了 findAll 方法,当你在浏览器中发送请求到 /cates 时程序就返回给你 This action returns all cats

++小提示++:可以使用 Nest-cli 工具来自动生成上面的代码:$ nest g controller cats

@Get() 表示 HTTP 请求装饰器。控制器类的装饰器和 HTTP 方法的装饰器共同决定了一个路由规则。findAll 将返回一个状态码为 200 的响应,当然你有两种方法来指定返回的状态码:

| 标准模式(建议的) | 使用内置方法时,如果返回一个 JavaScript 对象或者数据,将自动序列化成 JSON,如果是字符串将默认不会序列化,响应的返回状态码默认总是 200,除非是 POST 请求会默认设置成 201。可以使用 @HttpCode() 装饰器来改变它 | | 指定框架 | 也可以使用指定框架的请求处理方法,比如 Express 的响应对象。可以使用 @Res() 装饰器来装饰响应对象使用,这样以来你就可以使用类 Express API 的方式处理响应了:response.status(200).send() |

++警告++:你可以同时使用上面两种方法,但是 Nest 会检测到,同时标准模式会在这个路由上被禁用

请求对象

处理器一般需要访问到请求对象。一般配合 @Req() 装饰器来使用,请求对象包含查询字符串、参数、HTTP 头,请求体等。但是大多数情况只用到其中某个,我们可以单独使用指定的装饰器来装饰请求。

| @Request() | req | | @Response() | res | | @Next() | next | | @Session() | req.session | | @Param(key?: string) | req.params / req.params[key] | | @Body(key?: string) | req.body / req.body[key] | | @Query(key?: string) | req.query / req.query[key] | | @Headers(name?: string) | req.headers / req.headers[name] | 举个例子:比如我们只需要处理请求的查询字符串(query string),就可以使用 @Query 来装饰入参,这样取到的值就自然是一个 query string 的字典了。

@Get()
getHello(@Query() q: String): string {
    console.log(q)
    return this.appService.getHello();
}

如果我们的请求是:http://localhost:3000/?test=a

那么控制台将打印一个 { test: 'a' } 字典

++小提示++:建议安装 @types/express 包来获取 Request 的相关类型提示

资源

除了使用 @Get 装饰器,我们还可以使用其它 HTTP 方法装饰器。比如:@Put(), @Delete(), @Patch(), @Options(), @Head(), and @All(),注意 All 并不是 HTTP 的方法,而是 Nest 提供的一个快捷方式,表示接收任何类型的 HTTP 请求。

路由通配符

Nest 支持基于模式的路由规则匹配,比如:星号(*)表示匹配任意的字母组合。

@Get('ab*cd')

The 'ab*cd' 路由将匹配 abcd, ab_cd, abecd 等规则。同时:?, +, *, and () 通配符(wildcard)都可以使用

通配符 说明 示例 匹配 不匹配
* 匹配任意数量的任意字符 Law* Law, Laws, or Lawyer GrokLaw, La, or aw
*Law* Law, GrokLaw, or Lawyer. La, or aw
? 匹配任意单个字符 ?at Cat, cat, Bat or bat at
[abc] 匹配方括号中的任意一个字符 [CB]at Cat or Bat cat or bat
[a-z] 匹配字母、数字区间 Letter[0-9] Letter0, Letter1, Letter2 up to Letter9 Letters, Letter or Letter10

状态码

响应的默认状态码是 200,POST 则是 201,我们可以使用装饰器 @HttpCode(204) 来指定处理器级别的 默认 HttpCode 为 204

@Post()
@HttpCode(204)
create() {
  return 'This action adds a new cat';
}

如果想动态指定状态码,就要使用 @Res() 装饰器来注入响应对象,同时调用响应的状态码设置方法。

请求头

同样的我们可以使用 @Header() 来设置自定义的请求头,也可以使用 response.header() 设置

@Post()
@Header('Cache-Control', 'none')
create() {
  return 'This action adds a new cat';
}

路由参数

通常我们需要设置一些动态的路由来接收一些客户端的查询参数,通过指定路由参数可以很方便的捕获到 URL 上的动态参数到控制器中。

@Get(':id')
findOne(@Param() params): string {
  console.log(params.id);
  return `This action returns a #${params.id} cat`;
}

通过使用 @Param() 装饰器可以在方法中直接访问到路由装饰器 @Get() 中的的参数字典,:id 就表示匹配到所有的字符串,可以通过引用 params.id 在方法中访问到。

当然,就像前面学到的参数装饰器也可以指定到具体的某个参数值:

@Get(':id')
findOne(@Param('id') id): string {
  return `This action returns a #${id} cat`;
}

路由顺序

路由的注册顺序与控制器类中的方法顺序相关,如果你先装饰了一个 cats/:id 的路由,后面又装饰了一个 cats 路由,那么当用户访问到 GET /cats 时,后面的路由将不会被捕获,因为参数才都是非必选的。

Nestjs 框架教程(第二篇:入门)

1970-01-01 08:00:00

这篇教程起,你将会学习到 Nest 的几个核心点。为了更好的了解 Nest 应用中的模块,我们将开发一个有基本 CRUD[1] 功能的入门级应用。

实现语言

Nest 是 TypeScript 写的,所以天生就很好的并且渐进地支持 JavaScript。

依赖

保证你的操作系统上安装的 Node.js 版本大于 8.9.0 即可。

初始化

就像上节讲到的直接用 nest new project-name 就可以了。我们来回顾下目录结构:

src
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── util.ts

分别对应的功能如下表:

| app.controller.ts | 只有一个路由的控制器(controller)示例 | | app.module.ts | 应用程序的根模块(root module) | | util.ts | 应用程序的入口文件,使用 NestFactory 方法创建应用实例 | 在 util.ts 中我们可以看到,默认使用了 NestFactory 的 create() 静态方法返回创建的应用对象,此对应会实现 INestApplication 接口。

平台

Nest 的目标是一个平台无关的框架。这个意思就是说 Nest 本身并不造某个细分领域的轮子,他只构建一套构架体系,然后把一些好用的库或者平台融合进来。所以 Nest 可以衔接任何 HTTP 框架,默认支持 express 和 fastify 两个 web 框架。

| platform-express | Express 是一个 Node web 框架,有很多社区成熟的资源。@nestjs/platform-express 默认会被引入,大家都很熟悉了,用起来会容易上手 | | platform-fastify | Fastify 是一个高能低耗的框架,致力于最大化效率与速度 | 无论使用哪个平台,都要暴露自己的应用接口。上面两个平台暴露了对应的两个变量 NestExpressApplication and NestFastifyApplication

如下的代码会创建一个 app 对象,并且指定了使用 NestExpressApplication 平台:

const app = await NestFactory.create<NestExpressApplication>(ApplicationModule);

不过一般情况下不需要指定这个类型。


  1. Create, Read, Update, Delete 通常对应于数据的增删改查功能 ↩︎

Nestjs 框架教程(第一篇:简介)

1970-01-01 08:00:00

nest.js

教程目录

请注意:本教程结合官方文档内容并添加了许多我自己学习过种中的理解,存在许多个人观点

  1. Nestjs 框架教程(第一篇:简介
  2. Nestjs 框架教程(第二篇:入门
  3. Nestjs 框架教程(第三篇:控制器
  4. Nestjs 框架教程(第四篇:Providers
  5. Nestjs 框架教程(第五篇:模块
  6. Nestjs 框架教程(第六篇:中间件
  7. Nestjs 框架教程(第七篇:异常过滤器
  8. Nestjs 框架教程(第八篇:管道
  9. Nestjs 框架教程(第九篇:守卫
  10. Nestjs 框架教程(第十篇:拦截器
  11. Nestjs 框架教程(第十一篇:自定义装饰器)

Nest 是一个用于构建高效、可扩展的 Node.js 服务端应用框架,基于 TypeScript 编写并且结合了 OOP[1]、FP[2]、FRP[3] 的相关理念。并且设计上很多灵感来自于 Angular[4]

Angular 的很多模式又来自于 Java 中的 Spring 框架,依赖注入、面向切面编程等,所以你可以认为: Nest 是 Node.js 版的 Spring 框架

或许很多前端工程师看到这里就自动劝退了,事实上我以前也挺讨厌 Java 的(现在也不怎么喜欢),后来由于工作原因学习到了一些 Java 相关的知识后才发现自己的认识很片面。现在 WEB 后端主流的技术栈都基于 Spring 框架,框架必然是解决了很多实际问题,能学习到它的思想比它自己的出身、派系更重要。同时建议那些没有学习或者接触过 Java 的前端可以了解一些相关概念,不要拒绝,因为这可能会为你打开另一扇门。

可能在很多伪 FP 爱好者来看 OOP 是臃肿无用的东西。但是从使用角度讲:FP 小而美,OOP 大而全,如果不关注场景去讨论好坏没有任何意义。而且事实上这两者完全是不冲突的,可以结合得非常完美。不要被那些所谓的函数、面向对象的概念误导,能写出真正的好代码才是重要的。

如果你以前在使用 Node.js 开发后端应用时常常不知道如何规划代码关系,搞不清楚控制器、服务、模型和数据的关系,或者是你打算使用 Node.js 构建大型应用,那就建议你了解一下 Nest。

框架的哲学

在开始体验前,有必要简单介绍下 Nest 框架的的设计理念,我结合我自己的理解大概梳理下。

近几年由于 Node.js 的出现,JavaScript 成为了前端和后端的「lingua franca[5]」,前端方面出现了 Angular, React, Vue 等众多的 UI 框架,后端方面也有像 Express, Koa 这样优秀的框架出现,但这些框架都没有高效地解决一个核心问题 — 架构

官方的这段介绍和我看到的非常一致,注意作者说是高效地解决,我的理解是现在 Node.js 或者说 JavaScript 框架都是各做各的,都是些点,可能确实有做的很不错的,但是整体而言并没有一个把各种好东西串链起来做成一种通用模式的框架,或者说是架构。

这个问题主要有三方面原因:其一,现在大多前端工程师的工作范围还是局限于前端 UI 层,或者说视图层,后端一般都由更加成熟的一技术栈来实现;其二,Node.js 诞生于 2009 年,相比于 2002 就发第一版的 Spring 差的很远;其三,Node.js 实际上就是 JavaScript,这门语言本身也有很多缺陷,以至于无法胜任大型应用的架构场景。

虽然有这些问题但是我始终认为 Nest 是个很好的开端,或者说对于所谓的「全栈」工程师来讲是个好事。因为我认为在大型项目中构架层面的复用比代码层面的复用更重要。

安装

安装 Nest 最方便的方法就是使用它额外提供的一个 CLI 工具(需要安装 Node.js > 8.9 版本),使用下面的命令它可以帮你自己生成项目的目录结构和预定义的最小模块:

npm i -g @nestjs/cli
nest new project-name

执行后命令行可以看见它自动生成的文件:

➜  github.com nest new project-name
⚡  We will scaffold your app in a few seconds..

CREATE /project-name/.prettierrc (51 bytes)
CREATE /project-name/README.md (3370 bytes)
CREATE /project-name/nest-cli.json (84 bytes)
CREATE /project-name/nodemon-debug.json (163 bytes)
CREATE /project-name/nodemon.json (67 bytes)
CREATE /project-name/package.json (1808 bytes)
CREATE /project-name/tsconfig.build.json (97 bytes)
CREATE /project-name/tsconfig.json (325 bytes)
CREATE /project-name/tslint.json (426 bytes)
CREATE /project-name/src/app.controller.spec.ts (617 bytes)
CREATE /project-name/src/app.controller.ts (274 bytes)
CREATE /project-name/src/app.module.ts (249 bytes)
CREATE /project-name/src/app.service.ts (142 bytes)
CREATE /project-name/src/util.ts (208 bytes)
CREATE /project-name/test/app.e2e-spec.ts (561 bytes)
CREATE /project-name/test/jest-e2e.json (183 bytes)

? Which package manager would you ❤️  to use? yarn
▹▸▹▹▹ Installation in progress... ☕
🚀  Successfully created project project-name
👉  Get started with the following commands:

$ cd project-name
$ yarn run start

这时可以按提示,进入到 project-name 运行项目。如果看到下面的输出就表示成功了:

➜  github.com cd project-name
➜  project-name git:(master)yarn run start
yarn run v1.10.1
$ ts-node -r tsconfig-paths/register src/util.ts
[Nest] 26470   - 2019/06/30 下午8:58   [NestFactory] Starting Nest application...
[Nest] 26470   - 2019/06/30 下午8:58   [InstanceLoader] AppModule dependencies initialized +11ms
[Nest] 26470   - 2019/06/30 下午8:58   [RoutesResolver] AppController {/}: +5ms
[Nest] 26470   - 2019/06/30 下午8:58   [RouterExplorer] Mapped {/, GET} route +3ms
[Nest] 26470   - 2019/06/30 下午8:58   [NestApplication] Nest application successfully started +3ms

然后我们访问 http://localhost:3000 就可以看到 Hello World! 了。用编辑器打开目录结构如下图所示

nest.js-demo

自动生成的配置文件还是挺多的,我们现在暂不用关注这些,只需要知道大概是做什么的就行了。

从上面的命令行中可以看出来整个项目是用 ts-node 跑起来的,这样的目的就是在开发环境节去了编译 .ts 的过程(实际上是 ts-node 在背后做了这个事情)。我们只需要关注 src/util.ts 这个入口文件即可。

整个 util.ts 文件就 8 行代码,使用 Nest 的工厂函数创建了一个应用实例,并且监听 3000 端口。注意,Nest 默认会使用 ES 的 async/await 语法,所以你再也不用怕嵌套回调函数了,以同步的编码方式获取异步的效率。


  1. Object Oriented Programming — 面向对象的编程 ↩︎

  2. Functional Programming — 函数式的编程 ↩︎

  3. Functional Reactive Programming — 函数式响应式编程 ↩︎

  4. Angular is a platform for building mobile and desktop web applications. ↩︎

  5. 一种术语,表示通用语言 ↩︎

复联4是一集电视剧,3才是一部电影

1970-01-01 08:00:00

相信复联4的热度也应该过了,今天我来聊天复联4的观影感受以及我对漫威系列电影的一些看法。

当然我并不是一名合格的漫威迷,对漫威系列电影的一些细节也不是很了解,更没看过漫画。我只想说说做为一名普通的观电影者的感受。

我是如何喜欢上漫威电影的

我看的第一部温威电影就是《钢铁侠》,记得还是在上大学的时候。那种感觉就像是小时候看的变形金刚变成真人了。人与机器居然能结合的那么天衣无缝,简直酷毙了。这全要归功于托尼这个角色的设计。

托尼·史塔克是一名企业家、天才发明家兼极度自恋的花花公子 — 单是这描述就能让大多数人惊叹不已了。但是他身上却有着更重要的一样东西,反复看过几遍钢铁侠后你会发现托尼身上有一种人类理性的闪光点。

回顾每次托尼面临困境时,他似乎总是能像计算机一样进行精确计算从而得出问题的最佳解决方法。即:

用最少的代价,获取最大的收益

所有的逻辑分支他都会考虑进去。为了达到最终目标,他可以忍气吞声,放下牵挂。而且他还有很强大的意志力,这种能力可能就来自于「with great power comes great responsibility」。因为他自视甚高,他相信自己是天才,自己是被选中的。

当然他也是普通人,有爱、有恨也有罪过。你很难想像这样一个有有血有肉的常人同时结合了机器的优点。有了飞行、攻击、防御这些特殊技能后,托尼就可以完全放飞自己,因为他想到的东西立马就可以做到,随着顶尖科学技术的进化与探究,时间/空间这些以前限制人类的东西都有可能都会被打破。

从托尼身上你能看到人类思想的一种无限状态,在不受物理限制情况下人类的思维创意能达到什么样的高度?

在钢铁侠系列影片中我最喜欢的一个片部分就是钢铁侠盘旋冲上天空的那个片段,这远比看着航天飞机升空的感觉更加震撼。

iron-man-0.png

这仿佛就是冲破规则的束缚自由超越的一种象征

iron-man-03.png

iron-man-01.png

同时他也是个正常人类,有感情,有爱恨

iron-man-04.png

如何评价复联3

复联3是继钢铁侠系列之后我最喜欢的一部漫威电影。实际上我在评价一部电影的时候并不会太在意之前几部的质量。

因为我认为衡量一部好电影的标准应该是:

给任何一个人看完之后都有所受益就是一部好电影。这个受益可能是娱乐八卦,可能是爱恨情仇,也可能是角色创意或者影片本身要表达的某种哲理思想

并且我认为最重要的一点就是影片要表达的某种思想或者哲理,因为这是最能引发人反思的东西。

毫无疑问,复联3中灭霸这个角色是有史以来众多反派角色中最出色的一个。灭霸被设计成了一个外在与内在都很强大的角色。

外形上灭霸有着比普通从大的多的尺寸用以艺术上的对比效果。他的手可以轻松抓住一个人的头部(开头抓雷神的头),小物件放在他手里有很强的视觉冲击:

thanos-2.png

这让我想起了《三国志》中对典韦的一段描写:「形貌魁梧,旅力过人,有志节任侠。一手执定旗杆,立于风中,巍然不动…」

同时灭霸的声音也非常有特色,沉稳,冷酷中透着死一般的坚定。虽然女性观众可能更喜欢雷神或者洛基的声音。

内在方面主要由感情和信念理想两条主线:

与卡魔拉的父女亲情(感情线)

这一段是全剧中我最喜欢的一段,几乎没有任何瑕疵。这个不到5分钟的片段,好就好在无论你有没有看过前几部甚至是单看这一段,每个人都会理解到「中心思想」,当然这个中心思想因人而异。下面我就简单解析下我对这段剧情理解。


Red Skull(红骷髅)

What you seek lies front of you — 你想要的东西就在前面

as does what you fear — 就像你恐惧的一样

Red-Skull.png

为什么说「你想要的东西就在前面,就像你恐惧的一样」,我的理解是当一个人想要一样东西的欲望到极致的时候,就会有一种矛盾恐惧 — 怕得不到,同时也怕得到。得不到意味着自己的所有努力和牺牲就白费了,同时越不容易得到的东西总会给人一种越容易丢失错觉。

美好的东西都在一瞬间,不容易得到的东西一旦拥有失去也会很快。当你经历过这个轮回才会发现没有什么东西是美好的,美好的背后可能是你看不见的丑陋的牺牲,没有这些丑陋的牺牲美好也失去了意义。这一切都在于你是否准备好了拿等价的牺牲去交换…

Gamora(卡魔拉)

What’s this — 这是什么

Red Skull(红骷髅)

The price — 代价

Soul holds a special place among the infinity stones, you might say it has a certain wisdom — 灵魂宝石在众多无限宝石中有特殊的地位,或者说有一种特殊的智慧

Thanos(灭霸)这里灭霸还没意识到代价是什么

Tall me what it needs — 告诉我它需要什么

Red Skull(红骷髅)

to ensure that whoever possesses it understands its power… 为了保证持拥有它的人明白它的威力

the stone demands a sacrifice — 宝石需要一样「牺牲」

Thanos(灭霸)

of what — 什么

Red Skull(红骷髅)

in order to take the stone, you must lose that which you love — 为了得到宝石,你必须牺牲一件你的至爱

a soul for a soul — 一个灵魂换一个灵魂

注意此时灭霸和卡魔拉的心理状态的不同:

灭霸陷入了沉思,他可能在思考什么才算得上是自己的至爱,他一生做的事情太多,但回想起来似乎连自己都找不到哪件事情能表现出他的

thanos-3.png

此时卡魔拉内心窃喜,因为在她看来灭霸没有半点人性,更谈不上

gemera.png

于是他说了下面这段现在看来很残忍的话:

Gamora(卡魔拉)

all my life, i dreamed of a day… a moment… — 我一生都梦想有一天,此刻…

when you got what you deserved. and i always so disappointed. — 每当你如尝所愿,我就对你恨之入骨

But now… — 但是现在…

you kill and torture… and you call it mercy. — 你暴虐滥杀无辜… 你自许仁慈.

The universe has judged you. — 现在老天终于要惩罚你.

You asked it for a prize, and it told you no. — 你想用一个条件换宝石,但是你不会特呈的.

You failed. — 而且你已经输了.

And do you wanna know why? — 想知道为什么吗?

Because you love nothing, no one — 因为你从来没有爱过,一次都没有

thanos-5.png

这段话字字珠玑,开始或许灭霸还没有意思到自己至爱到底是什么,但是没过多久他就开始意识到自己的至爱就是卡魔拉,此时眼角已经有泪痕。

此时剧情已到了关键的转折点,灭霸的背影则暗喻着一位父亲的高大形象

thanos-6.png

Thanos(灭霸)

No. — 不.

注意此刻的灭霸说 No 的语气,绝对不是想去否定些什么,而像是一种安慰。充满爱意的「No」,如果他是其它角色相信后面的台词都出自动出现在我们脑子里「No, my litter gril, i love you more than i can say…」

thanos-7.png

灭霸实际上是非常的孤独,所有他的想法都不被周围的人认可,虽然他强大的内心可以完全不在意外人的评判,但是他还做不到不在乎自己的女儿对他的评价。所以反射性的去否定卡魔拉,他非常坚定的相信自己是爱自己女儿的,同时也表露出了即将要失去女儿的真挚情感。

Gamora(卡魔拉)此时的卡魔拉还没有反应过来,她认为灭霸是因为自己的计划无法得逞而为自己流泪。

Really, tears? — 真的吗,你也会为自己流泪?

她还没有发现自己在恨父亲的同时实际上对灭霸也是有感情的,正所谓爱之深、恨之切…

Red Skull(红骷髅)

They’are not for him. — 他并不是为自己

thanos-10.png

还没来得及反应,红骷髅的话惊醒了她,同时她似乎也感受到了她与灭霸之间的那份父女之情(脸色表情也开始变化,赞演技),真正的爱恰恰是只能通过感觉而来,不用任何语言来表达的。

内心已经感觉到了自己与灭霸之间的亲情,这是无法否定的,此时她也陷入了无助

thanos-11.png

虽然是这样理智还是让她不肯相信这个事实,于是卡魔拉也开始否定。

No, this isn’t love — 不,这不是爱

Thanos(灭霸)很难想像灭霸在这一小段时间内,内心情绪有多么的复杂。他终其一生想要完成的事业却要让他失去至爱。他是一个无比理想化的存在,为了自己的理想可以放弃任何东西,而且他已经错过一次命运,所以这次他选择了完成自己的宿命。

I ignored my destiny once, i can't do that again, Even for you — 我已经错过一次命运了,不能再错一次,即使以失去你为代价

thanos-12.png

此时灭霸的脸上完成没有了恶霸的冷酷表情,而是一种对于将要失去至爱的无助与失落…

thanos-13.png

接着他亲手将自己的女儿仍下悬崖,但此刻灭霸的表情似乎呆住了,可能他自己也不能相信他自己的所做所为

thanos-14.png

thanos-15.png

自己的信念(理想线)

一个从不说谎的人只有一种可能,就是及其的理想主义,在他们的认知里面只有真理、对错以及自己的信念。从灭霸和卡魔拉的对话就能看出。

You are strong — me

You're generous — me

But i never taought you to lie

当然外在的的强大只是陪衬,他身上最能感染人的是那种不惜一切代价追求自己理想的一种信仰

从这个层面来讲,钢铁侠和灭霸的信仰是平等的。在这个层面他们之间没有矛盾,没有正义/邪恶 之分。理由很简单:你认为拯救更多的人类是你的正确信仰,那我认为通过随机消灭一半生命可以让宇宙达到一种平衡也是我的正确信仰

托尼和灭霸想要维护的东西不在一个层面上,所以无法判断对方是否正确。我觉得复联3留给观众的一个最大的问题就是

托尼拯救地球的理想是理想,那灭霸要维护宇宙的平衡的理想算不算是理想?

人类看待这个问题会受限于自身的环境,理性上受制于时间空间,感性上受制于感情。所以在这个问题上多数人连判断的资格都没有

第3部在我看来是这一个巨大的切入点,并期待第4部能有所解释

回头再看复联4

仅过了一年,我带着期待去看了复联4,但并没有达到我的预期。因为大家都了把复联当成了一部电视剧在看。电影本身也几乎没有了电影的元素,除了各种超级英雄的梗外我实在没看出来有什么亮点。

这可能也符合了事物发展的规律,当有太多人喜欢一样东西、追求一样东西的时候事情就变味儿了,毕竟电影公司做为商业机构是要维护自己的品牌,要照顾多数人的情感。就好比电视剧演久了观众就会有了心理预期,很难创新一样。

但是复联4做为 MCU 电影的标致性阶段,也算是一个好的结尾,毕竟要照顾到多数的人喜好,同时要融合各种英雄人物的离场,还要演的不那么生硬确实太难了。

总的来说,复联3杂而不乱,主线支线明显,影片整体有层次感,剧情紧凑连续演员的演技也非常棒。复联4则是让所有普通的每一个人(类)都享受到了一种胜利的喜悦。

也许 Markdown 并不是一个好选择

1970-01-01 08:00:00

我经常会使用 markdown 来写一些东西。比如:博客文章、技术文档什么的。但是时间长了总是会觉得编辑 Markdown 源码的写作方式太容易让人分心了。

Markdown 确实是一个非常好的通用排版格式,因为它很简单,学习使用起来没有门槛。但是随着人们越来越多的使用 Markdown 创作,Markdown 本身的一些问题也显露出来。比如:当你需要一些高级排版格式的时候 Markdown 是无能为力的,更不用说表格编辑这类重排版的工作在 Markdown 中的编辑体验了。

当然开源社区会有一些开源项目来扩展 Markdown 的功能,甚至是用 Markdown 来画流程图。这且没有什么问题,问题是当你使用了一些扩展的高级功能时又想把他扩展到其它系统(比如博客),这时你又不得不改造博客来适配这些功能,Markdown 也就丧失了它的便携性。所以说使用 Markdown 的关键问题在于:

如何能更简单方便的使用 Markdown,同时又不失一些好用的功能

如何解决编辑的体验问题

编辑排版最佳的体验毫无疑问是「所见即所得」的模式。因为人们总是喜欢改完东西立即看见效果。目前常用的 Markdown 编辑器通常分左右两列:++源码++ | ++预览++。实际上我认为这种模式并不好,甚至是错的。因为整个编辑过程会非常痛苦,你不得不既关注源码里面的格式,如:空格、Markdown 符号,还得关注预览出来的效果是不是满意然后再调整。

相比之下 Typora 给出了稍好一些的体验方案 — 富文本的编辑模式 + Markdown 源码格式。这应该也是目前 Markdown 最好的编辑方式了,但是在我看来还是不够好。

我自己在书写 Markdown 文件的时候会格外注意格式排版,比如:标点符号,中英文空格,分行留白等,Typora 的编辑模式并不能让我免于这样的困扰(本质就在于 Typora还是要让用户自己去编辑源码),我还是不得不关注 Markdown 的那些符号,这些符号就像听音乐时的耳机里面的「杂音」一样。当你专注于写作,一口气写下上千字的时候,很容易就会被这种杂音打断,灵感转瞬即逝…

如何添加一些功能特性

这个问题我认为不应该给 Markdown 扩展功能,因为扩展 Markdown 只能通过添加更多符号的形式实现。而这会增加它的复杂度,不同平台都要适配才行,最终让 Markdown 格式变的不可交换

各种编辑方式按功能强大的排序应该是:Makrdown < 富文本 < Word。复杂的功能应该由后面两种工具来胜任,Markdown 应该做为一种格式上的约定。重功能不需要它来实现,比如编辑表格的体验在 Markdown 里简直就是灾难,但是在富文本或者 Word 中却异常简单。这就像是 Markdown 应该做为++接口++来定义一些规范,而不应该让它去关心具体++实现++。

结论

既然如此那有没有更好的解决方案呢(广告预警)。我正在开发一款基于网页的 WYSIWYG[1] 富文本编辑器应用,并试着解决上面的这些问题,解决 Markdown 问题的同时又能获得富文本编辑的优质体验,主要面向有写作和编辑文章/笔记需求的用户。如果你也刚好有这个需求,不妨试试:

wtdf.io — 基于网页的所见即所得 Markdown 写作应用


  1. What You See Is What You Get — 中译「所见所得」 ↩︎

选择太多所以迷失方向

1970-01-01 08:00:00

前几天无意在微博上看见了《流浪汉沈巍自述》一文,此文来自一个上海的流浪汉语录。

不同的是他并不是为了生计而流浪捡破烂,沈巍从小喜欢捡破烂,捡完破烂换了钱买书看。家庭环境不理解更不支持,到了社会上,他本来可以按大多数人眼中的 正常 人一样工作,一辈子当公务员。

但是他还是改不了自己捡破烂的习惯,这样以来单位也容不下他。想法完全不与主流融合,只能被流浪。可以即便是这样,在看它的文字里,你仍然能感受到字里行间都散发着对生活的无限向往,对信念的追求,以及对残酷现实的一丝温柔。

有人说故事分两种:一种开始就讲给你最美好的东西,最完美无缺的事物,人性最善良的部分。后来慢慢的什么都变了,以前那些看似美好的东西都有了瑕疵,人性也没能经得住时间的考验,所有的认识都支离破碎;还有一种一开始就告诉你最丑恶的东西,最让人恶心、难受的事物,人性最黑暗的一面。后来慢慢的也似乎变了,不经意的发现好像还有那么一丝光明的东西,一点点能让人感动的事情。

前一种更像是从教科书到现实的一种过程,可能很多人慢慢的都受不了这种落差,逐渐没有了精神支柱,厌恶了生活。后一种则看似悲观、反面,实则能激发出人们本能善良的一面。

这让我想起了一句话:

人不是活一辈子,不是活几年几月几天,而是活那么几个瞬间。

对于沈巍来说,社会和家庭给他的都是排斥、否认和异样的眼光。但是可能他就只能从书中找到了那么一丝光明,一些让自己感动的瞬间吧。

我们可以反思下我们的现实生活。现实中我们总是说「自己没有选择,我不得不这样、没有退路…」。但事实上真的是这样吗?或许正是因为我们拥有的太多才让自己没法选择。

人是很奇怪的,当你某天没有加班,工作完成后早早回家后突然发现居然还有很多时间可以安排。这时候你会想做很多事情:玩手机、睡觉、好好做顿饭、看部电影…

但当你真正的面临很多种选择时,自己会去权衡。可能自己精神上很需要放松一下,需要娱乐一下。但是理智又告诉你应该做一些「有意义」的事情。最终可能一件事都没做好。

这时候其实你需要用 肉体操纵精神,不要想,先去做。因为实际上当你持续专注的做一件事情的时候,精神上会特别放松,你不需要再考虑那么多的选择,只需要 像机器 一样去做好一件事情。

人总是可以通过做事情来让自己的 内心变的安稳。我曾经无意中听到两人女生聊天,其中一个女生说

我特别喜欢洗衣服,因为当我特别专注的把衣服洗干净的时候,那种感觉特别安静,虽然需要你耗费一些体力,但是洗完后你的内心会有一种解脱,一种如释重负的感觉…

我听到这段聊天的时候是特别惊讶的,原话特别有感染力。后来我发现了这种感觉就像是我平常写代码一样,一写起代码,就很专注,似乎能忘记时间。当你解决了一个问题,完成了一个功能模块的时候长呼一口吸、伸个懒腰,瞬间感觉特别满足。即使回到现实中你还得面临很多复杂的事情,但在这一时刻你是自由的。

后来我也理解了那句 Nick 经典广告语「Just do it」的深刻涵义,当然这和我选择做 IT 行业是两码事。

图:https://www.pexels.com/photo/abstract-bright-color-dark-397998/

使用 Pixi.js 构建一个视差滚动器(第三篇)

1970-01-01 08:00:00

翻译对照

原文: PART 1PART 2PART 3PART 4

译文: 第一篇第二篇第三篇・ 第四篇


关注 @chriscaleb

这个系列的教程已经更新到了 PixiJS v4 版本。

欢迎再次来到这个系列教程的第三部分,这一节将会涉及到如何使用 pixi.js 制作视差滚动游戏的地图。整个教程到目前为止已经涵盖了很多内容。在第一个教程中,我们学习了一些 pixi.js 基础知识,并将视差滚动应用于几个层上。在第二部分,通过代码重构将一些面向对象的概念应用到实践中。这一节我们将把重点放在第三个更复杂的视差层上,它将代表玩家角色在游戏时将会穿越的地图。

你将学到什么…

预备知识…

我们将继续从上一个教程结束的地方开始。你可以使用前两个教程编写的代码,也可以从 GitHub 下载第二个教程的源代码。也可以在 GitHub上 找到第三节完整教程的 源代码,即使你遇到了问题,我也鼓励你完成本教程,有疑问可以请仅参考源代码。

这个系列的教程非常受到 CanabaltMonster Dash 游戏的启发,当玩家的英雄在平台之间奔跑和跳跃时,这些游戏都能很好地利用视差滚动来提供花哨的视觉效果。

在接下来的两节教程中,我们将构建一个非常类似于 Monster Dash 中的滚动游戏地图。 Monster Dash 的游戏地图是由一系列不同宽度和高度的砖块儿构建而成。游戏的目的是通过在砖块儿之间跳跃来尽可能长地生存。游戏地图的滚动速度随着时间的推移而增加。

ps-tut1-screenshot1

上面就是你这一节将要完成的示例。单击图片即可查看包含砖块儿和间隙的滚动地图。

起步

如果你还没有看过第一节和第一节教程,我建议你应该先看完这两节。

在本节教程中,我们将使用一些新的图片素材。可以直接从 这里 下载,并将其内容解压缩到项目的 resource 文件夹中。

下面就是你的 resource 文件夹的样子(Windows):

ps-tut3-screenshot1

macOS 下则是这样:

ps-tut3-screenshot2

此外,如果你还没有建立一个本地的 web 服务器,请参考第一节的内容。

值得注意的是,本教程比前两篇长。你可能需要大约两个小时才能完成所有工作。

游戏地图

正如上面的演示中展示的那样,我们的游戏地图有很多种展示形式。如砖块儿的宽度和高度各不相同。每个跨度还包括一系列窗户和墙壁装饰元素。墙壁装饰本身由管道和通风口组成。

那么墙跨度是如何构建的?每个跨度都是由一系列拼接在一起的垂直切片构成的。每个切片的大小为 64 x 256 像素。下图显示了示例砖块儿。

diagram-1

通过垂直移动每个切片的位置来处理砖块儿的高度。下面的示意图中我们可以看到,第二个面墙的切片部分位于视口的可见区域下方(译者:超出视口),使其看起来低于第一面墙。

diagram-2

大多数情况,一而墙内的每个切片将会是水平对齐的。但有一个例外。 Monster Dash 有一个阶梯式的跨度,让玩家可以直接跌落到下一个水平线上。以下是它的构造方式:

diagram-3

如果你仔细观察上面的示意图,你应该注意到这里真正的是墙面有两个(第一个跨度高于第二个),它们通过中间的一个切片(台阶)连接起来。

你可能会惊讶地发现我们的整个游戏地图只由八种不同类型的垂直切片构成:

diagram-4

这些切片的顺序很重要。我们再来谈谈这个问题。

分解砖块墙

一面砖块墙包括三个主要部分:

  1. 前边缘
  2. 中间部分
  3. 后边缘

前/后边缘都只由一个垂直切片表示。然而,中间部分可以由一个或多个切片制成。切片越多,墙跨度就越长。我们将制作一面有 30 个切片的砖块墙。下图可以解释砖块墙的三大部分。

diagram-5

墙的中间部分只有下面两种切片:

因此整个墙的中间部分长度为 6,结构如下:

window, decoration, window, decoration, window, decoration

然而,通常情况下,砖块墙的中间部分是非偶数个切片才能保证出现的容器即有亮灯的也有灭的。所以我们使用 7 个切片来制作中间部分

window, decoration, window, decoration, window, decoration, window

为了保证砖块墙尽可能看起来有趣,窗户可以点亮或不点亮,我们可以随机选择三种装饰切片。因此,墙的中间部分将由五种不同类型的切片构成。

为了增加更多的切片种类,我们从砖块墙的边缘素材中(两个)选择两个切片做为前后边缘(译者:边缘素材有两个,可以随机选一个做前边缘,然后翻转它做成后边缘,但是不能一个做前一个做后,示意图中的 front & back 和图片没有对应关系),后边缘也可以使用同样的前边缘,因为我们只需要把它(前边缘)水平翻转然后正确地拼接到后边缘即可。台阶切片很少会出现,所以我们只需要用一个切片。

diagram-6-1024x531

打开上面的素材,单独放在一个浏览器 tab 里面,可以方便制作时查看它。

不要将切片 类型 与用于构建指定砖块墙的切片数混淆。例如,一面砖块墙可以有 30 个垂直切片,但实际上只由 8 类切片构建。

现在你已经了解了砖块墙是如何构建的,我们可以开始实现它了。

精灵表(Sprite sheet)

如上所述,我们的砖块墙由八种不同类型的砖块构成。表示这些切片的最简单方法是为每个切片提供单独的 PNG文件。虽然这是一种办法,但我们实际上会将所有切片添加到一个称为 精灵表 的大型 PNG 文件中。

精灵表通常也称为 纹理图集(texture atlas) 。我们将在本教程中使用 精灵表 这个术语。

我在本教程的 resources.zip 文件中提供了精灵表。这是一个名为 wall.png 的文件,如下所示。所有八个切片都已打包到一个位图上。

sprite-sheet

资源文件夹中还有一个与精灵表对应的 wall.json 文件。可以直接用文本编辑器打开。此文件使用 JSON 数据格式来定义精灵表中单独位图切片的名称和位置。使用精灵表时,表中的每个单独的位图称为

我们的整个精灵表将作为纹理加载到代码中(中间层和远景图层也这么加载过)。因此,有时会将框架视为子纹理。

并不需要完全理解 JSON 文件,因为 Pixi 将处理它。但是,我们可以探索一下正在使用的这个文件。下面这段是来自 JSON 数据中的一段,表示第一个墙边切片的框架。我已经为高亮了一些代码行:

"edge_01": // 高亮
{
  "frame": {"x":128,"y":0,"w":64,"h":256},// 高亮
  "rotated": false,
  "trimmed": false,
  "spriteSourceSize": {"x":0,"y":0,"w":64,"h":256},
  "sourceSize": {"w":64,"h":256}
},

第一行包含与框架关联的 唯一名称edge_01):

"edge_01":

每当我们想要从精灵表中直接获取这个墙切片的图像时,我们将使用此名称。

如果你不熟悉 JSON 数据格式,则可以在此 Wikipedia 条目 中找到更多信息。

下一个高亮行代码定义了框架的矩形区域:

"frame": {"x":128,"y":0,"w":64,"h":256},

本质上,它用于在精灵表中定位帧的位图。

JSON 文件中还有其他七种类型的切片。每个切片将由唯一的帧名称表示。使用精灵表时,你只需要知道 唯一名称 即可。下面我还提供了一张标有每个切片类型的图片。也可以单独打开这个图片,方便回顾。

wall.json 的后面,有一些元数据:

"meta": {
  "app": "http://www.codeandweb.com/texturepacker ",
  "version": "1.0",
  "image": "wall.png",
  "format": "RGBA8888",
  "size": {"w":256,"h":512},
  "scale": "1",
  "smartupdate": "$TexturePacker:SmartUpdate:fc102f6475bdd4d372c..."
}

在该数据表示精灵表的实际文件的相对路径。 Pixi 将使用该数据加载正确的 PNG 文件。

纹理打包器(TexturePacker)

我使用了一个工具来生成本教程的精灵表和 JSON 文件。它的名字叫 TexturePacker,可用于Windows,Mac OS X 和 Linux。它可以导出许多精灵表格式,包括 pixi.js 使用的JSON(哈希)格式。我不会在本教程中介绍如何使用 TexturePacker,但它非常容易掌握。付费版本也物超所值,还有一个免费版本,适合那些想先学习基础知识的人。

加载精灵表

既然我们对精灵表有一点了解了,就让我们继续把它加载进程序。我们首先将一些代码添加到项目的 Main 类中。用文本编辑器中打开 Main.js。

在文件的末尾,添加以下方法来加载精灵表:

Main.prototype.loadSpriteSheet = function() {
  var loader = PIXI.loader;
  loader.add("wall", "resources/wall.json");
  loader.once("complete", this.spriteSheetLoaded.bind(this));
  loader.load();
};

我们使用了 PIXI.loaders.Loader 类,它可用于加载图像,精灵表和位图字体文件。我们直接从 PIXI.loader 属性获取加载器的预定义的实例来使用加载器,所有资源都可以人这里加载。所以,只需把 wall.json 文件也添加进去。我们传递一个与文件关联的唯一 ID 作为第一个参数,并将资源的实际相对路径作为第二个参数传递。

加载精灵表后,PIXI.loaders.Loader 类会触发一个 complete 事件。为了响应该事件,我们只需要绑定 complete 方法到自定义函数 spriteSheetLoaded() 中,这个函数我们稍后实现。

最后,调用我们的 PIXI.loaders.Loader 实例的 load() 方法来真正加载我们的精灵表。加载完后,Pixi 将提取所有帧并将其存储在内部的纹理缓存中以便后续使用。

目前,远景层和中间层图像在其构造函数中加载。但是,我们实际上可以预先加载这些图像,并避免在实例化远景层和中间类时出现短暂的延迟。将它们添加到我们的 Loader 实例中:

loader.add("wall", "resources/wall.json");
loader.add("bg-mid", "resources/bg-mid.png"); // 添加
loader.add("bg-far", "resources/bg-far.png"); // 添加

无需对 Far 或 Mid 类进行任何更改,因为在尝试从文件系统加载纹理之前,对 PIXI.Texture.fromImage() 的调用将优先查询内部纹理缓存。

现在让我们编写 spriteSheetLoaded() 方法。在文件末尾添加以下内容:

Main.prototype.spriteSheetLoaded = function() {
};

我们需要编写这个空方法。之前我们创建了一个 Scroller 类的实例,并在 Main 类的构造函数中启动了我们的主循环。但是,我们现在要等到精灵表加载完成后再进行所有操作。让我们将该代码移动到我们的 spriteSheetLoaded() 方法中。

向上滚动到构造函数并删除以下两行:

function Main() {
  this.stage = new PIXI.Container();
  this.renderer = PIXI.autoDetectRenderer(
    512,
    384,
    {view:document.getElementById("game-canvas")}
  );

  this.scroller = new Scroller(this.stage); // 删除

  requestAnimationFrame(this.update.bind(this)); // 删除
}

再回到你的 spriteSheetLoaded() 方法并在那里添加删除的两行:

Main.prototype.spriteSheetLoaded = function() {
  this.scroller = new Scroller(this.stage);
  requestAnimationFrame(this.update.bind(this));
};

最后,返回构造函数并调用 loadSpriteSheet() 方法:

function Main() {
  this.stage = new PIXI.Container();
  this.renderer = PIXI.autoDetectRenderer(
    512,
    384,
    {view:document.getElementById("game-canvas")}
  );

  this.loadSpriteSheet(); // 添加
}

现在保存代码并刷新浏览器。在 Chrome 的 JavaScript 控制台中查看没有错误。

测试精灵表

虽然我们已经成功加载了精灵表,但我们并不知道帧(我们的八个垂直壁切片类型)是否已真正地存储在 Pixi 的纹理缓存中。所以让我们继续创建一些使用其中一些精灵来使用这使用帧。

我们将在 spriteSheetLoaded() 方法中执行我们的测试。将以下代码添加到其中:

Main.prototype.spriteSheetLoaded = function() {
  this.scroller = new Scroller(this.stage);
  requestAnimationFrame(this.update.bind(this));

  var slice1 = PIXI.Sprite.fromFrame("edge_01"); // 高亮
  slice1.position.x = 32; // 高亮
  slice1.position.y = 64; // 高亮
  this.stage.addChild(slice1); // 高亮
};

在上面的代码中,我们利用了 PIXI.Sprite 类的 fromFrame() 静态方法。它使用纹理缓存中与指定帧 ID 匹配的纹理创建一个新的精灵。我们指定 edge_01 帧用来表示砖块墙前边缘的切片。

保存代码并刷新浏览器以查看切片。不用担心它展示的位置,位置现在还不重要。

让我们添加第二个垂直切片。这次我们将使用砖块墙中间的切片类型。为了更精确,我们将使用精灵表中名为decoration_03 的帧:

Main.prototype.spriteSheetLoaded = function() {
  this.scroller = new Scroller(this.stage);
  requestAnimationFrame(this.update.bind(this));

  var slice1 = PIXI.Sprite.fromFrame("edge_01");
  slice1.position.x = 32;
  slice1.position.y = 64;
  this.stage.addChild(slice1);

  var slice2 = PIXI.Sprite.fromFrame("decoration_03"); // 添加
  slice2.position.x = 128; // 添加
  slice2.position.y = 64; // 添加
  this.stage.addChild(slice2); // 添加
};

再次保存并测试。现在应该看到两个垂直墙切片位于舞台上,类似于下面的这个屏幕截图。

tut3-testing-sprite-sheet

希望你现在对精灵表的框架已成功加载并缓存产生了一些成就感。从 spriteSheetLoaded() 方法中删除测试代码。方法应再次如下所示:

Main.prototype.spriteSheetLoaded = function() {
  this.scroller = new Scroller(this.stage);
  requestAnimationFrame(this.update.bind(this));
};

保存你的修改

一些 GPU 理论

我还没有解释为什么我们选择将切片打包成一个精灵表而不是单独加载八个 PNG 到内存中。原因和性能相关。 Pixi 的 WebGL 渲染器利用计算机的图形处理单元(GPU)来加速图形性能。但是为了保证最佳性能,我们必须至少了解一点 GPU 的工作原理。

GPU 更擅长一次处理大数据量的场景。 Pixi 会迎合 GPU 的这个特点,把数据对象批量发送给 GPU。但是,它只能批量处理具有相似状态的展示对象。当遇到具有不同状态的显示对象时,表示已经发生状态改变并且 GPU 会停止以绘制当前批次。程序中发生的状态更改越少,GPU 需要执行的绘制操作就越少,以便呈现展示列表。 GPU 执行的绘制操作越少,渲染性能就越快。

刚刚提到的 绘制(draw) 操作和我们平常绘画意思差不多。

不幸的是,每当遇到具有不同纹理的展示对象时,状态就会发生改变。精灵表可以帮助避免状态更改,因为所有图像都存储在单个纹理中。 GPU 可以非常愉快地从精灵表中绘制每个帧(或子纹理),而无需单独的调用绘制。

但是,可以存储在 GPU 上的纹理存在大小限制。大多数现代 GPU 可以存储大小为 2048×2048 像素的纹理。因此,如果你要使用精灵表,请确保其尺寸不超过 GPU 纹理的限制。值得庆幸的是,我们的精灵表很小。

因此,与将每个墙切片的图像存储在单独的纹理上相比,我们的精灵表可以帮助显着提高滚动器的性能。

展示游戏地图

所以我们已经成功加载了精灵表并且还设法显示了一些帧,但是我们如何真正地构建一个包含砖块墙的大地图?

我想最简单的方法是创建一个精灵数组,其中每个精灵代表我们地图中的垂直墙切片。然而,考虑到每个切片的宽度比较短,我们的整个地图将很容易由数千个精灵组成。这是很多精灵都将存储在内存中。另外,如果我们只是将所有这些精灵转储到我们的展示列表上,那么它会给渲染器带来很大的压力,可能会影响游戏的帧速率。

另一种方法是实例化并仅显示将在视口中可见的精灵。当地图滚动时,最左边的精灵最终将离开屏幕。当发生这种情况时,我们可以从显示列表中删除该精灵,并在视口最右边的外部添加一个新的精灵。通过这种方法,我们可以向用户提供滚动整个地图的错觉,而实际上只需要处理视口中当前可见的地图部分。

虽然第二种方法肯定比第一种方法更好,但它需要为我们的精灵进行不断的内存分配和释放:为进入的每个新精灵分配内存,为离开的精灵释放内存。为什么这么做比较糟糕呢?因为分配内存需要宝贵的 CPU 周期,这可能会影响游戏的性能。如果你必须不断地分配内存,那将避免不了这个问题。

释放之前对象使用的内存也是潜在的 CPU 性能损耗。 JavaScript 运行时利用垃圾收集器释放以前被不再需要的对象使用的内存。但是,你无法直接控制何时进行垃圾收集,假如需要释放大量内存,该过程可能需要几毫秒。因此,不断实例化精灵再从展示列表中删除精灵将导致频繁的垃圾收集,这会影响游戏的性能。

第三种方法可以避免前两种问题。它被称为 对象池,它能在不触发 JavaScript 的垃圾收集器的情况下更加智能地使用内存。

对象池(Object Pooling)

想理解对象池,请考虑一个简单的游戏场景。在射击游戏中,玩家的船可能会在游戏过程中发射数十万枚射弹,但由于船的射速,任何时候都只能有 20 枚射弹进入屏幕。因此,仅在游戏代码中创建 20 个射弹实例并在游戏过程中重新使用这些射弹是更好的。

20 个射弹可以存放在一个阵列中。每次玩家开火时,我们从阵列中移除一个射弹并将其添加到屏幕上。当射弹离开屏幕(或击中敌人)时,我们将其添加回阵列以便稍后再次使用。重要的是我们永远不需要创建新的射弹实例。相反,我们只使用预先创建的 20 个实例池。在我们的示例中,数组将是我们的对象池。这样合理吗?

如果你想了解有关对象池的更多信息,请查看此 Wikipedia条目

我们可以将对象池应用到游戏地图中,并具有以下内容:一个窗口(window)切片池;一幢墙面装饰(decoration)切片;一层前边缘;一层后边缘;还有一个台阶。

因此,虽然我们的游戏地图最终可能包含数百个窗口,但实际上我们只需要创建足够的窗口精灵来覆盖视口的宽度。当一个窗口即将在我们的视口中显示时,我们只需从 windows 对象池中检索一个窗口精灵。当该窗口滚出视图时,我们将其从显示列表中删除并将其返回到对象池。我们将这个原则应用于边缘,装饰和台阶。

知道这就足够了。让我们开始构建一个对象池类来保存我们的切片精灵。

创建一个对象池类

由于我们的游戏地图代表了一系列砖块墙,我们将创建一个名为 WallSpritesPool 的类,作为我们各种墙壁部件的池子。

更通用的类名可能是 MapSpritesPool,也可以是 ObjectPool。但是,就本教程而言,WallSpritesPool 是比较合适的。

在文本编辑器中创建一个新文件并添加以下构造函数:

function WallSpritesPool() {
  this.windows = [];
}

保存文件并将其命名为 WallSpritesPool.js

在构造函数中,我们定义了一个名为 windows 的空数组。此数组将充当我们地图中所有的窗口精灵的对象池。

给 windows 池子添加元素

我们的数组需要预先填充一些窗口精灵。请记住,我们的砖块墙可以支持两种类型的窗户 — 一个开灯的窗户和一个没有开灯的窗户 - 所以我们需要确保我们添加两种类型足够多。通过将以下代码添加到构造函数来填充数组:

function WallSpritesPool() {
  this.windows = [];

  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
}

上面的代码为对象池添加了 12 个窗口精灵。前 6 个精灵代表我们亮灯的窗口(window_01),而余他 6 个精灵代表未亮灯的窗口(window_02)。

从对象池中检索精灵时,它们将从数组的前面获取。根据我们在填充时将精灵添加到数组中的顺序,对窗口精灵的前 6 个请求将始终返回一个亮灯的窗口,而接下来的 6 个请求将始终返回一个未亮灯的窗口。我们从池中获得的窗口切片类型需要 随机 出现。这可以通过在填充数组后数组元素进行打乱来实现。

以下方法将把传递给它的数组打乱。添加方法:

WallSpritesPool.prototype.shuffle = function(array) {
  var len = array.length;
  var shuffles = len * 3;
  for (var i = 0; i < shuffles; i++)
  {
    var wallSlice = array.pop();
    var pos = Math.floor(Math.random() * (len-1));
    array.splice(pos, 0, wallSlice);
  }
};

现在从构造函数调用 shuffle() 方法:

function WallSpritesPool() {
  this.windows = [];

  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_01"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
  this.windows.push(PIXI.Sprite.fromFrame("window_02"));
  
  this.shuffle(this.windows); // 调用
}

现在让我们做一些重构,因为有一个更简洁的方法来填充我们的数组。由于我们实际上是在数组中添加两组精灵(亮灯和不亮灯的窗口),我们可以替换以下代码行:

function WallSpritesPool() {
  this.windows = [];

  this.windows.push(PIXI.Sprite.fromFrame("window_01")); // 删除
  this.windows.push(PIXI.Sprite.fromFrame("window_01")); // 删除
  this.windows.push(PIXI.Sprite.fromFrame("window_01")); // 删除
  this.windows.push(PIXI.Sprite.fromFrame("window_01")); // 删除
  this.windows.push(PIXI.Sprite.fromFrame("window_01")); // 删除
  this.windows.push(PIXI.Sprite.fromFrame("window_01")); // 删除
  this.windows.push(PIXI.Sprite.fromFrame("window_02")); // 删除
  this.windows.push(PIXI.Sprite.fromFrame("window_02")); // 删除
  this.windows.push(PIXI.Sprite.fromFrame("window_02")); // 删除
  this.windows.push(PIXI.Sprite.fromFrame("window_02")); // 删除
  this.windows.push(PIXI.Sprite.fromFrame("window_02")); // 删除
  this.windows.push(PIXI.Sprite.fromFrame("window_02")); // 删除
  
  this.shuffle(this.windows);
}

用下面的代替:

function WallSpritesPool() {
  this.windows = [];

  this.addWindowSprites(6, "window_01"); // 添加
  this.addWindowSprites(6, "window_02"); // 添加
  
  this.shuffle(this.windows);
}

 // 添加
WallSpritesPool.prototype.addWindowSprites = function(amount, frameId) {
  for (var i = 0; i < amount; i++)
  {
    var sprite = PIXI.Sprite.fromFrame(frameId);
    this.windows.push(sprite);
  }
};

WallSpritesPool.prototype.shuffle = function(array) {
  var len = array.length;
  var shuffles = len * 3;
  for (var i = 0; i < shuffles; i++)
  {
    var wallSlice = array.pop();
    var pos = Math.floor(Math.random() * (len-1));
    array.splice(pos, 0, wallSlice);
  }
};

保存更改。

addWindowSprites() 方法允许我们向 windows 数组中添加一些在精灵表中指定的精灵帧。因此,它可以很容易地为我们的池子添加一组 6 个亮灯精灵和一组 6 个未亮灯精灵。

在继续之前,我们应该再做一次重构。将构造函数中的代码移动到单独的方法中。删除以下行:

function WallSpritesPool() {
  this.windows = []; // 删除

  this.addWindowSprites(6, "window_01"); // 删除
  this.addWindowSprites(6, "window_02"); // 删除
  
  this.shuffle(this.windows); // 删除
}

使用一个新方法替换:

WallSpritesPool.prototype.createWindows = function() {
  this.windows = [];

  this.addWindowSprites(6, "window_01");
  this.addWindowSprites(6, "window_02");

  this.shuffle(this.windows);
};

最后,从构造函数中调用 createWindows() 方法:

function WallSpritesPool() {
  this.createWindows();
}

好的,我们目前用代码创建了窗口精灵,将它们添加到一个数组,并打乱该数组。继续之前保存文件。

为什么使用十二个窗口精灵

从技术上讲,我们可以在池中使用少于 12 个窗口精灵。毕竟,我们只需要足够的精灵来覆盖视口的宽度。我选择十二个的原因是为了让砖块墙的亮灯和不亮灯窗户具有一些随机性。然而值得注意的是,我可以在合理范围内使用任意数量的精灵,只要它为我提供足够的窗口精灵以在视口内生成砖块墙。

借用(borrow)和归还(return)精灵

我们的对象池有一组窗口精灵,但是我们还没有提供从池中获取精灵或返回池的公共方法。

所有方法和属性都可以在 JavaScript 中公开访问。这可能使你难以识别属于你的类 API 的方法和属性以及处理实现细节的方法和属性。当我把某些东西称为“公开”时,我的意思是说我打算在类的外部使用它。

我们将提供以下两种方法:

borrowWindow() 方法将从 windows 池中删除一个窗口精灵,并返回对它的引用供你使用。完成后,可以通过调用 returnWindow() 将精灵作为参数传递回游戏池。

好的,我们在类的构造函数之后添加 borrowWindow() 方法:

function WallSpritesPool() {
  this.createWindows();
}
// 添加
WallSpritesPool.prototype.borrowWindow = function() {
  return this.windows.shift();
};

正如你所看到的,这是一个相当简单的方法,它只是从 windows 数组的前面删除第一个精灵并返回它。

borrowWindow() 方法不会检查池中是否还有精灵。我们在这一系列教程中都不会太在意这种异常情况,但在尝试从中返回内容之前,检查一下精灵池是否为空是一个好习惯。有多种策略可用于处理空池子。一个常见的方法是在干燥(没有元素)时动态增加池的大小。

现在直接在其下面添加 returnWindow() 方法:

WallSpritesPool.prototype.borrowWindow = function() {
  return this.windows.shift();
};
// 添加	
WallSpritesPool.prototype.returnWindow = function(sprite) {
  this.windows.push(sprite);
};

就像 borrowWindow() 一样,returnWindow() 方法很简单。它将精灵作为参数并将该精灵压入到 windows 数组的末尾。

我们现在有一种从对象池中借用窗口精灵的方法,一旦我们完成它就将精灵返回给(归还)对象池的方法。

保存更改。

快速回顾

查看一下 WallSpritesPool 类。并没有很多代码,但重要的是你要了解在添加之前发生了什么。以下是类的当前版本:

function WallSpritesPool() {
  this.createWindows();
}

WallSpritesPool.prototype.borrowWindow = function() {
  return this.windows.shift();
};
	
WallSpritesPool.prototype.returnWindow = function(sprite) {
  this.windows.push(sprite);
};

WallSpritesPool.prototype.createWindows = function() {
  this.windows = [];

  this.addWindowSprites(6, "window_01");
  this.addWindowSprites(6, "window_02");

  this.shuffle(this.windows);
};

WallSpritesPool.prototype.addWindowSprites = function(amount, frameId) {
  for (var i = 0; i < amount; i++)
  {
    var sprite = PIXI.Sprite.fromFrame(frameId);
    this.windows.push(sprite);
  }
};

WallSpritesPool.prototype.shuffle = function(array) {
  var len = array.length;
  var shuffles = len * 3;
  for (var i = 0; i < shuffles; i++)
  {
    var wallSlice = array.pop();
    var pos = Math.floor(Math.random() * (len-1));
    array.splice(pos, 0, wallSlice);
  }
};

该类只创建一个包含 6 个亮灯窗口精灵和 6个未亮灯窗口精灵数组。该数组充当窗口的精灵池,并且被打乱以确保随机混合两种状态。提供了两个公共方法 — borrowWindow()returnWindow() - 它们允许从精灵池中借用一个窗口精灵,然后归还到池中。

这就是它要做的所有事情了。当然,我们仍然需要考虑其他切片类型(前边缘,后边缘,墙面装饰和墙壁台阶),但我们很快就会将它们添加到我们的 WallSpritesPool 类中。首先让我们把将精灵池的代码引用到页面,保证正常运行。

测试你的对象池

转到你的 index.html 文件并引用 WallSpritesPool 类的源文件:

<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/4.0.0/pixi.min.js"></script>
<script src="Far.js"></script>
<script src="Mid.js"></script>
<script src="Scroller.js"></script>
<script src="WallSpritesPool.js"></script> <!-- 添加 -->
<script src="Main.js"></script>

保存代码。

现在打开 Main.js。我们将对 Main 类进行一些临时更改,以便测试对象池。

我们首先在 spriteSheetLoaded() 方法中创建我们的对象池的实例,创建将用于保存从池中获取的切片精灵数组:

Main.prototype.spriteSheetLoaded = function() {
  this.scroller = new Scroller(this.stage);
  requestAnimationFrame(this.update.bind(this));

  this.pool = new WallSpritesPool(); // 添加
  this.wallSlices = []; // 添加
};

在上面的代码中,我们将对象池实例存储在名为 pool 的成员变量中,而我们的数组的成员变量名为 wallSlices

现在让我们编写一些代码来从池中获取指定数量的窗口并将它们连续地添加到舞台上。添加以下测试方法:

Main.prototype.borrowWallSprites = function(num) {
  for (var i = 0; i < num; i++)
  {
    var sprite = this.pool.borrowWindow();
    sprite.position.x = -32 + (i * 64);
    sprite.position.y = 128;

    this.wallSlices.push(sprite);

    this.stage.addChild(sprite);
  }
};

除了将窗口精灵添加到舞台,上面的 borrowWallSprites() 方法还将每个精灵添加到我们的 wallSlices 成员变量中。这样做的原因是我们需要能够从第二个测试方法中访问(删除、移除、归还)这些窗口精灵,我们现在将编写它们。添加以下内容:

Main.prototype.returnWallSprites = function() {
  for (var i = 0; i < this.wallSlices.length; i++)
  {
    var sprite = this.wallSlices[i];
    this.stage.removeChild(sprite);
    this.pool.returnWindow(sprite);
  }

  this.wallSlices = [];
};

这个 returnWallSprites() 方法删除添加到舞台的所有窗口切片,并将这些精灵归还到对象池。

通过这两种方法,我们可以验证我们是否可以从对象池中借用窗口精灵,并将这些精灵归还给池子。我们将使用Chrome 的 JavaScript 控制台窗口:

刷新浏览器并打开JavaScript控制台。手动执行如下代码:

main.borrowWallSprites(9);

请记住,我们的 Main 类可以通过主全局变量 main 访问,我们可以使用它来调用 borrowWallSprites() 方法。

就像下面的截图一样,你应该看到舞台上有九个窗口精灵。都是从你的对象池中 来的,然后被添加到舞台上。还要注意,亮灯和亮灯的窗口序列可能是随机出现的。这是因为池中的窗口数组在创建后被打乱了。

tut3-testing-object-pool

现在让我们验证是否可以将这些精灵归还给对象池。在控制台中输入以下内容:

main.returnWallSprites();

精灵墙应该从舞台上消失,并将返回到对象池。

这还不能满足我们的实际需示。最简单的方法是从池中请求更多窗口并检查它们是否也出现在屏幕上。让我们从游泳池中再借用九个窗口:

main.borrowWallSprites(9);

然后再归还:

main.returnWallSprites();

我们现在从对象池中获得了总共18个精灵。请记住,池中只包含 12 个窗口精灵(6个开灯的,6 个不开灯的)。因此,精灵正在从池中借用并在我们完成后成功返回。如果没有被返还,那么当对象池的内部数组变空时,会报运行时错误。

JavaScript 中的所有内容都可以公开访问,我们可以在任何时候轻松检查对象池的内部数组。尝试从控制台检查数组的大小:

main.pool.windows.length

这么做应该返回长度 12。现在使用以下方法从池中借用四个窗口精灵:

main.borrowWallSprites(4);

再次查看池子中的精灵个数:

main.pool.windows.length

它现在应该只包含 8 个精灵。最后通过调用 returnWallSprites() 将精灵集返回池中。再次检查对象池的大小,并确认其长度为 12。

我对咱们的对象池能正常运行感到满意。让我们继续,但保留你添加到 Main 类的测试代码,因为我们很快就会再次使用它。

向对象池中添加墙面装饰

目前我们的对象池仅提供窗口精灵,但我们还需要添加对前边缘,后边缘,墙面装饰切片和台阶的支支持。让我们从三个墙面装饰切片开始。

如果你还记得,我们的一些墙上装饰着管道和通风口。这些切片安插在在每个窗口之间。让我们更新我们的 WallSpritesPool 类以包含墙面装饰切片。代码与口的对象池非常相似,所以它们看起来都应该很熟悉。

打开 WallSpritesPool.js 并在构造函数中进行以下调用:

function WallSpritesPool() {
  this.createWindows();
  this.createDecorations(); // 添加
}

现在真正来实现 createDecorations() 方法:

WallSpritesPool.prototype.createWindows = function() {
  this.windows = [];

  this.addWindowSprites(6, "window_01");
  this.addWindowSprites(6, "window_02");

  this.shuffle(this.windows);
};
// 实现
WallSpritesPool.prototype.createDecorations = function() {
  this.decorations = [];

  this.addDecorationSprites(6, "decoration_01");
  this.addDecorationSprites(6, "decoration_02");
  this.addDecorationSprites(6, "decoration_03");

  this.shuffle(this.decorations);
};

上面的代码通过调用 addDecorationSprites() 方法将 18 个装饰精灵添加到对象池中(稍后我们将实现这个方法)。前六个精灵使用我们的精灵表中的 decoration_01 帧。接下来的六个使用 decoration_02,最后六个使用 decoration_03。然后调用 shuffle() 确保精灵随机放置在我们的装饰数组中,我们已将其声明为此类的成员变量,并用于存储墙面装饰精灵。

现在让我们来编写 addDecorationSprites() 方法。在 addWindowSprites() 方法之后直接添加以下内容:

WallSpritesPool.prototype.addWindowSprites = function(amount, frameId) {
  for (var i = 0; i < amount; i++)
  {
    var sprite = new PIXI.Sprite(PIXI.Texture.fromFrame(frameId));
    this.windows.push(sprite);
  }
};
// 实现
WallSpritesPool.prototype.addDecorationSprites = function(amount, frameId) {
  for (var i = 0; i < amount; i++)
  {
    var sprite = new PIXI.Sprite(PIXI.Texture.fromFrame(frameId));
    this.decorations.push(sprite);
  }
};

现在剩下要做的就是添加两个新方法,允许从对象池借用装饰精灵并返还。方法名称将遵循用于窗口精灵的命名约定。添加 borrowDecoration()returnDecoration() 方法:

WallSpritesPool.prototype.borrowWindow = function() {
  return this.windows.shift();
};
	
WallSpritesPool.prototype.returnWindow = function(sprite) {
  this.windows.push(sprite);
};
// 实现
WallSpritesPool.prototype.borrowDecoration = function() {
  return this.decorations.shift();
};
	
WallSpritesPool.prototype.returnDecoration = function(sprite) {
  this.decorations.push(sprite);
};

保存代码。

我们的对象池现在支持窗口和装饰两种切片类型。让我们回到之前添加到 Main类中的测试方法,并测试一切是否正常。

对象池的测试

前面我们建造了一面粗糙墙,完全由我们的对象池中的窗口组成。让我们稍微改变我们的测试代码,在每个窗口之间放置装饰切片。这将可以测试到是否真的可以从对象池中借用到窗口切片和装饰切片。

打开 Main.js 并从 borrowWallSprites() 方法中删除以下行:

Main.prototype.borrowWallSprites = function(num) {
  for (var i = 0; i < num; i++)
  {
    var sprite = this.pool.borrowWindow(); // 删除
    sprite.position.x = -32 + (i * 64);
    sprite.position.y = 128;

    this.wallSlices.push(sprite);

    this.stage.addChild(sprite);
  }
};

用下面几行代替:

Main.prototype.borrowWallSprites = function(num) {
  for (var i = 0; i < num; i++)
  {
    if (i % 2 == 0) { // 添加
      var sprite = this.pool.borrowWindow(); // 添加
    } else { // 添加
      var sprite = this.pool.borrowDecoration(); // 添加
    } // 添加
    sprite.position.x = -32 + (i * 64);
    sprite.position.y = 192;

    this.wallSlices.push(sprite);

    this.stage.addChild(sprite);
  }
};

上面的代码使用模运算符(%)来确保我们在循环的奇数次迭代借用一个窗口精灵,偶数次迭代时借用一个装饰精灵。这个简单的更改允许我们现在生成具有以下模式的测试砖块墙:

window, decoration, window, decoration, window, decoration, window

现在转到 returnWallSprites() 方法并删除以下行:

Main.prototype.returnWallSprites = function() {
  for (var i = 0; i < this.wallSlices.length; i++)
  {
    var sprite = this.wallSlices[i]; // 删除
    this.stage.removeChild(sprite);
    this.pool.returnWindow(sprite);
  }

  this.wallSlices = [];
};

用下面几行代替:

Main.prototype.returnWallSprites = function() {
  for (var i = 0; i < this.wallSlices.length; i++)
  {
    var sprite = this.wallSlices[i];
    this.stage.removeChild(sprite);

    if (i % 2 == 0) { // 添加
      this.pool.returnWindow(sprite); // 添加
    } else { // 添加
      this.pool.returnDecoration(sprite); // 添加
    } // 添加
  }

  this.wallSlices = [];
};

我们再次使用了模运算符,这次确保我们将正确的精灵(窗口或装饰)返回给对象池。

保存代码。

刷新浏览器,然后使用 Chrome 的 JavaScript 控制台测试我们的对象池。通过在控制台窗口中输入以下内容来生成测试墙:

main.borrowWallSprites(9);

如果不出意外,那么你应该看到一个由窗户构成的测试墙,其间插有各种墙壁装饰,如管道和通风口。实际上,你的砖块墙应该类似于下面的图片,它是从我的开发机上截取的。

tut3-more-object-pool-testing

虽然我们目前只编写了一些简单的测试,但我们所做的并不是为了生成整个游戏地图。

使用以下调用将精灵返还到对象池:

main.returnWallSprites();

通过对 borrowWallSprites()returnWallSprites() 进行一些手动调用来验证对象池是否完全正常工作(译者:建议多调用几次验证程序是否正常)。此外,使用控制台检查对象池的窗口和装饰数组的长度是否正常。

给你的对象池添加边缘

我们正一步步走向成功。精灵池目前使得我们可以创建一个原始的砖块墙,但它还没有墙的前后边缘。让我们继续添加这些切片类型。

在文本编辑器中打开 WallSpritesPool.js 并将以下两行添加到其构造函数中:

function WallSpritesPool() {
  this.createWindows();
  this.createDecorations();
  this.createFrontEdges(); // 添加
  this.createBackEdges(); // 添加
}

现在添加一个 createFrontEdges() 和一个 createBackEdges() 方法:

WallSpritesPool.prototype.createDecorations = function() {
  this.decorations = [];

  this.addDecorations(6, "decoration_01");
  this.addDecorations(6, "decoration_02");
  this.addDecorations(6, "decoration_03");

  this.shuffle(this.decorations);
};
// 添加
WallSpritesPool.prototype.createFrontEdges = function() {
  this.frontEdges = [];

  this.addFrontEdgeSprites(2, "edge_01");
  this.addFrontEdgeSprites(2, "edge_02");

  this.shuffle(this.frontEdges);
};
// 添加
WallSpritesPool.prototype.createBackEdges = function() {
  this.backEdges = [];

  this.addBackEdgeSprites(2, "edge_01");
  this.addBackEdgeSprites(2, "edge_02");

  this.shuffle(this.backEdges);
};

你应该能够轻松地看出来两种方法在干什么。第一个方法创建四个前边缘切片,其中两个使用精灵表的 edge_01 帧,另外两个使用 edge_02。第二个方法创建四个后边缘切片,并使用精灵表中与前边缘完全相同的帧。

四个前壁边缘可能看起来相当少,但它会绰绰有余,因为即使砖块墙长度很短也至少会占视口一半宽度。换句话说,我们在任何时候都不会使用超过四个前壁边缘。后墙边缘也是如此。

现在继续添加 addFrontEdgeSprites()addBackEdgeSprites() 方法:

WallSpritesPool.prototype.addDecorationSprites = function(amount, frameId) {
  for (var i = 0; i < amount; i++)
  {
    var sprite = new PIXI.Sprite(PIXI.Texture.fromFrame(frameId));
    this.decorations.push(sprite);
  }
};
// 添加
WallSpritesPool.prototype.addFrontEdgeSprites = function(amount, frameId) {
  for (var i = 0; i < amount; i++)
  {
    var sprite = new PIXI.Sprite(PIXI.Texture.fromFrame(frameId));
    this.frontEdges.push(sprite);
  }
};
// 添加
WallSpritesPool.prototype.addBackEdgeSprites = function(amount, frameId) {
  for (var i = 0; i < amount; i++)
  {
    var sprite = new PIXI.Sprite(PIXI.Texture.fromFrame(frameId));
    sprite.anchor.x = 1;
    sprite.scale.x = -1;
    this.backEdges.push(sprite);
  }
};

上面的代码没什么特殊的地方,但 addBackEdgeSprites() 方法中有几行值得注意:

var sprite = new PIXI.Sprite(PIXI.Texture.fromFrame(frameId));
sprite.anchor.x = 1; // 高亮行
sprite.scale.x = -1;// 高亮行
this.backEdges.push(sprite);

由于我们使用的是前边缘所使用的相同的精灵帧,我们需要水平翻转后边缘精灵,以便它们适当地贴合在砖块墙的的末端。下图能说明我的意思。它在翻转之前显示后边缘。它与墙跨没有正确连接,看起来不对。

flipping-wall-edges-1

然而,在翻转后的后边缘精灵,会紧贴着砖块墙的末端。如下图。

flipping-wall-edges-2

翻转精灵很容易。我们只需使用 PIXI.Sprite 类的 scale 属性即可。 scale 属性具有 x 和 y 值,可以调整该值以更改 sprite 的大小。但是,将 scale.x 值设置为 -1,我们可以强制精灵水平翻转而不是缩放。

Pixi 的 PIXI.Sprite 类还提供了一个 anchor 属性,用于定义 sprite 的锚点(轴心点)。默认情况下,精灵的锚点在左上角。你可以设置锚点的 x 和 y 位置以调整精灵的锚。anchor.set() 方法设置用于 x 和 y 位置的 比率值0,0 表示精灵的左上角,1,1 表示其右下角。

在我们的教程中只使用默认值,这意味着所有定位都在精灵的左上角。然而,通过水平翻转边缘精灵,我们也翻转了它们的锚点的位置。换句话说,在水平翻转精灵之后,它的原点会改变到它的右上角,这不是我们想要的。为了解决这个问题,我们在将它们水平翻转之前将精灵的原点设置为右上角。这样,翻转后,它将被正确设置到左上角。

好的,现在让我们来编写可以借用边缘并返还给对象池的方法。

WallSpritesPool.prototype.returnDecoration = function(sprite) {
  this.decorations.push(sprite);
};
// 添加
WallSpritesPool.prototype.borrowFrontEdge = function() {
  return this.frontEdges.shift();
};

WallSpritesPool.prototype.returnFrontEdge = function(sprite) {
  this.frontEdges.push(sprite);
};

WallSpritesPool.prototype.borrowBackEdge = function() {
  return this.backEdges.shift();
};

WallSpritesPool.prototype.returnBackEdge = function(sprite) {
  this.backEdges.push(sprite);
};

保存你的代码。

构建第一个完整的砖块墙

我们的精灵池现在支持足够多的垂切片类型,可以用来构建完整的砖块墙了。记住,一块完整的砖块墙包括 前边缘中间部分后边缘。中间部分至少应包括 窗户 和墙壁 装饰。一些砖块墙也可能包括一个 台阶

让我们回到 Main 类,并编写一些测试代码,在我们的视口中绘制一个完整的砖块墙。

首先,删除以前的测试方法。打开 Main.js 并删除 borrowWallSprites()returnWallSprites()

我们将实现一个名为 generateTestWallSpan() 的新方法,用它来生成七个切片宽度的砖块墙。我们将把所有切片存放在一张表里面。首先添加以下内容:

Main.prototype.generateTestWallSpan = function() {
  var lookupTable = [
    this.pool.borrowFrontEdge,  // 第一个切片
    this.pool.borrowWindow,     // 第二个切片
    this.pool.borrowDecoration, // 第三个切片
    this.pool.borrowWindow,     // 第四个切片
    this.pool.borrowDecoration, // 第五个切片
    this.pool.borrowWindow,     // 第六个切片
    this.pool.borrowBackEdge    // 第七个切片
  ];
}

这张表是一个存放函数引用的数组。数组中的每个索引代表七个切片中的一个。第一个索引表示墙的前边缘,最后一个表示后边缘。中间的指数代表代表墙壁中段的五个切片。

每个索引都包含对构建砖块墙所需的对象池中对应的引用。例如,第一个索引包含对池的 borrowFrontEdge() 方法的引用。第二个索引包含对 borrowWindow() 的引用,第三个索引包含对 borrowDecoration() 的引用。

Main.prototype.generateTestWallSpan = function() {
  var lookupTable = [
    this.pool.borrowFrontEdge,  // 1st slice
    this.pool.borrowWindow,     // 2nd slice
    this.pool.borrowDecoration, // 3rd slice
    this.pool.borrowWindow,     // 4th slice
    this.pool.borrowDecoration, // 5th slice
    this.pool.borrowWindow,     // 6th slice
    this.pool.borrowBackEdge    // 7th slice
  ];
  // 添加
  for (var i = 0; i < lookupTable.length; i++)
  {
    var func = lookupTable[i];

    var sprite = func.call(this.pool);
    sprite.position.x = 32 + (i * 64);
    sprite.position.y = 128;

    this.wallSlices.push(sprite);

    this.stage.addChild(sprite);
  }
};

在循环内部,我们的代码获取对应切片的借用方法的引用,并将其存储在名为 func 的局部变量中:

var func = lookupTable[i];

一旦我们有了这个正确的引用,就使用以下方法调用它:

var sprite = func.call(this.pool);

call() 是一种原生的 JavaScript 方法,可用来从函数引用调用函数。例如,在循环的第一次迭代中,func 变量将指向精灵池的 borrowFrontEdge() 方法。因此,调用 funccall() 方法与下面的代码等价:

this.pool.borrowFrontEdge()

有了生成测试墙的方法,我们也需要编写另一个名为 clearTestWallSpan() 的清除墙的方法。此方法将从舞台移除砖块墙并将切片返还到对象池中。

在你的文件中加入下面的代码:

Main.prototype.clearTestWallSpan = function() {
  var lookupTable = [
    this.pool.returnFrontEdge,  // 1st slice
    this.pool.returnWindow,     // 2nd slice
    this.pool.returnDecoration, // 3rd slice
    this.pool.returnWindow,     // 4th slice
    this.pool.returnDecoration, // 5th slice
    this.pool.returnWindow,     // 6th slice
    this.pool.returnBackEdge    // 7th slice
  ];

  for (var i = 0; i < lookupTable.length; i++)
  {
    var func = lookupTable[i];
    var sprite = this.wallSlices[i];

    this.stage.removeChild(sprite);
    func.call(this.pool, sprite);
  }

  this.wallSlices = [];
};

我们再一次使用了一张表。但是这次我们存储的是对应的切片返还方法的引用。例如,我们知道砖块墙的第一个切片是墙的前边缘。因此,存储在表中的第一个方法是 returnFrontEdge()

另外,请注意,这次使用原生 JavaScript call() 方法时,我们将第二个参数传递给它。第二个参数是我们想要返还给池子的精灵。

保存更改并刷新浏览器。让我们看看完整的砖块墙是什么样的。

打开 Chrome 的 JavaScript 控制台并执行生成砖块墙的代码:

main.generateTestWallSpan();

你应该会看到七个切片宽的砖块墙。还有前后边缘。你的浏览器窗口应类似于下面的屏幕截图。

wall-span-screenshot-1

七个切片都是从我们的对象池中借来的。让我们通过在控制台中输入以下内容来返还它们:

main.clearTestWallSpan();

切片精灵应该会被从舞台上移除并返回到你的对象池中。

再次生成砖块墙:

main.generateTestWallSpan();

你会再次看到砖块墙,但这次你看到墙壁上的装饰与上次不同,窗口类型也可能会有所不同,甚至前后边缘的外观也会发生变化。

wall-span-screenshot-2

这些差异是由于我们这次借用了不同的墙片造成的。我们之前的切片返回到了每个对象池的数组 最后面,而借用的精灵总是来自我们数组的 前面。这样效果会比较好,因为玩家很难准确预测从池中获取每个切片类型的样子。它会让我们游戏地图的墙块随机出现,这正是我们想要的。

给砖块墙添加台阶

希望你能从上面的实现代码中得到成就感。我们能够使用对象池构建完整的砖块墙。现在剩下要做的就是为对象池添加台阶的支持。让我们继续吧。

返回文本编辑器并确保 WallSpritesPool.js 已打开。

添加下面一行到构造函数中。

function WallSpritesPool() {
  this.createWindows();
  this.createDecorations();
  this.createFrontEdges();
  this.createBackEdges();
  this.createSteps(); // 添加
}

现在来实现 createSteps() 方法:

WallSpritesPool.prototype.createSteps = function() {
  this.steps = [];
  this.addStepSprites(2, "step_01");
};

并且添加一个 addStepSprites() 方法:

WallSpritesPool.prototype.addStepSprites = function(amount, frameId) {
  for (var i = 0; i < amount; i++)
  {
    var sprite = new PIXI.Sprite(PIXI.Texture.fromFrame(frameId));
    sprite.anchor.y = 0.25;
    this.steps.push(sprite);
  }
};

台阶很少会出现,虽然我们将在精灵池中只使用两个。但说实话,但已经足够了。

此外,就像后边缘切片类型一样,我们使用了 anchor 属性来改变精灵的锚点。这次我们通过向下移动 64 像素来改变锚点的垂直位置。请记住,使用锚属性的值是比率。每个切片的高度为 256 像素,将锚点的 y 位置向下移动 64 个像素对应的比率为 0.25。

那么为什么要改变锚属性呢?好吧,当我们最终实际生成游戏地图时,一定范围的所有切片将使用相同的 y 位置以确保正确对齐。但是,台阶切片位图的设计使其成为特例 — 它将无法与砖块墙的其他切片正确对齐。你可以在下图中发现这种情况,其中所有切片(包括台阶)具有相同的 y 位置并且其锚点设置在左上角。

wall-step-anchor-1

如你所见,台阶的垂直位置显然是不正确的。但是,通过将其锚点向下移动 64 像素,我们可以强制它在砖块墙内正确展示。下图中就是设置过的,其中每个切片(包括台阶)仍然 共享 相同的 y 位置,但由于其锚点已被移动,步骤切片现在正确地位于砖块墙内。

wall-step-anchor-2

现在我们需要做的就是提供允许我们从对象池借用并返回一个步骤的方法。添加以下 borrowStep()returnStep() 方法:

WallSpritesPool.prototype.borrowStep = function() {
  return this.steps.shift();
};

WallSpritesPool.prototype.returnStep = function(sprite) {
  this.steps.push(sprite);
};

将更改保存到文件。对象池类现已完成了。

测试砖块墙的台阶

这一节的教程即将完成。让我们通过生成包含台阶的测试砖块墙来结束它。

打开 Main.js 并删除 generateTestWallSpan() 方法中的代码。将其替换为以下内容:

Main.prototype.generateTestWallSpan = function() {
  var lookupTable = [
    this.pool.borrowFrontEdge,  // 1st slice
    this.pool.borrowWindow,     // 2nd slice
    this.pool.borrowDecoration, // 3rd slice
    this.pool.borrowStep,       // 4th slice
    this.pool.borrowWindow,     // 5th slice
    this.pool.borrowBackEdge    // 6th slice
  ];

  var yPos = [
    128, // 1st slice
    128, // 2nd slice
    128, // 3rd slice
    192, // 4th slice
    192, // 5th slice
    192  // 6th slice
  ];

  for (var i = 0; i < lookupTable.length; i++)
  {
    var func = lookupTable[i];

    var sprite = func.call(this.pool);
    sprite.position.x = 64 + (i * 64);
    sprite.position.y = yPos[i];

    this.wallSlices.push(sprite);

    this.stage.addChild(sprite);
  }
};

generateTestWallSpan() 几乎与前一版相同。这次墙只有六个切片宽,我们还添加了第二个名为 yPos 的数组。

如果查看这张表,你将发现第 4 个索引表示台阶切片。请记住,该步骤可让玩家直接跌落到正下方的墙面上。如果你回想一下教程的开头,你应该记住,当我们处理一个步骤时,我们实际处理的是两个连接在一起的独立砖块墙。第一个砖块墙将高于第二个,台阶切片本身将属于第二个砖块墙。

两个砖块墙之间的高度差异由我们的 yPos 数组处理。它对于我们的每个切片都有一个 y 位置。前三个切片 y 都是 128 个像素,而剩余的切片是 192个像素。

让我们转到我们的 clearTestWallSpan() 方法。从现有版本的方法中删除代码,并将其替换为以下内容:

Main.prototype.clearTestWallSpan = function() {
  var lookupTable = [
    this.pool.returnFrontEdge,  // 1st slice
    this.pool.returnWindow,     // 2nd slice
    this.pool.returnDecoration, // 3rd slice
    this.pool.returnStep,       // 4th slice
    this.pool.returnWindow,     // 5th slice
    this.pool.returnBackEdge    // 6th slice
  ];

  for (var i = 0; i < lookupTable.length; i++)
  {
    var func = lookupTable[i];
    var sprite = this.wallSlices[i];

    this.stage.removeChild(sprite);
    func.call(this.pool, sprite);
  }

  this.wallSlices = [];
};

如你所见,表中包含对将每个切片返还到对象池所需的所有方法的引用,包括台阶。

保存更改并刷新浏览器。

在 JavaScript 控制台中输入以下内容:

main.generateTestWallSpan();

你应该会在屏幕上看到一个带有台阶的墙。它应该看起来像这样:

wall-step-screenshot

再返还整个砖块墙给对象池:

main.clearTestWallSpan();

多试几次生成砖块墙然后返还到对象池,确保一切都正常。

整理代码

我们不断地测试对象池,现在它已经成型。为了准备本系列的最后一个教程,我们现在从 Main 类中删除测试代码:

Main.prototype.spriteSheetLoaded = function() {
  this.scroller = new Scroller(this.stage);
  requestAnimationFrame(this.update.bind(this));

  this.pool = new WallSpritesPool();
  this.wallSlices = [];
};

还要完全删除 generateTestWallSpan()clearTestWallSpan() 方法。

现在保存你的更改。

结语

感谢你能坚持到这里。本教程已经涉及到了大量的内容。我们已经讨论了滚动游戏地图的各种技术点,并了解了为什么选择使用对象池。

虽然本教程很长,但对象池的概念实际上相当简单。不过有人可能会很容易陷入到一些实现细节中,但记住最重要的一点对象池只一个非常简单的 API:有一组从池中借用精灵,另一组返还这些精灵。

我们还学到了更多关于 pixi.js 的知识,包括精灵表和 PIXI.Sprite 类的其它功能。此外,我们也介绍了 GPU 加速的好处,以及为什么使用精灵表可以带来巨大的性能提升。

虽然我们还没有真正地开始构建滚动游戏地图,但我们已经编写了一些代码来生成一些测试砖块墙。这应该有助于你了解如何使用对象池,也可以帮助你熟悉砖块墙的结构和游戏地图。

下期预告

下一节中我们将真正的添加流动游戏中的第三层。和前两层不一样,第三层将组成整个游戏地图所需要的砖块墙。这些切片都将从我们的对象池中借取。

与往常一样,GitHub上提供了本系列和之前教程的 源代码

很快你将开始教程的的 第四部分,也是最后一部分。

使用 Pixi.js 构建一个视差滚动器(第二篇)

1970-01-01 08:00:00

翻译对照

原文: PART 1PART 2PART 3PART 4

译文: 第一篇第二篇第三篇・ 第四篇


关注 @chriscaleb

这个系列的教程已经更新到了 PixiJS v4 版本。

在这个系列教程中我们将探索如何构建一个类似 CanabaltMonster Dash 的视差滚动地图游戏界面。第一篇介绍了 pixi.js 的渲染引擎并且涉及到了视差滚动的基础知识。现在我们将在上一篇的基础之上添加 视口 的概念。

你将学到什么…

预备知识…

你将以第一篇教程中的代码为基础,或者直接下载上篇教程中的 源代码,另外整个教程的完全源代码也在 github 上可以找到。

ps-tut1-screenshot1

作为提示,点击上面的图片,将会加载当前版本的视差滚动,目前来说只有两个层,我们将添加每三个更复杂的层。与此同时,我们将通过添加视口的概念来添加第三层。我们还会执行一些重要代码重构,以便将滚动器封装在类中。

虽然本教程非常针对那些对面向对象有基础概念的初学者级别,如这些概念让你感到不舒服,也不用担心,因为我仍然会为那些不熟悉这些枞的的人提供足够的指导。

起步

如果你还没有看过第一篇教程,我建议你应该从那篇开始。

还有一点值得提醒的是,为了能够测试你的代码,你需要开启一个本地的 web 服务器。如果你还没有做这一步,那么可能参考上一篇教程中的章节建立好自己的 web 服务器。

扩展 pixi.js 的 展示对象

正如我们之前发现的,pixi.js 提供了几种可使用的 展示对象 类型。如果你还记得的话,我们在使用 PIXI.extras.TilingSprite 来满足我们的需求之前,先简单地使用了 PIXI.Sprite

这两个类共享许多公用的功能。例如,它们都为你提供位置(position),宽度(width),高度(height)和 alpha 属性。此外,两者都可以通过 addChild() 方法添加到容器中。事实上,PIXI.Container 类本身就是一个 展示对象,它还提供了许多 Sprite 和 TilingSprite 类都能使用的属性。

所有这些公用的功能都来自于 继承(inheritance) 的魔力。它使得类可以继承和扩展功能到其它类上。为了让你能理解它,可以参考下面的示意图,它将为你展示 pixi.js 中提供的大多数展示对象

ps-tut2-screenshot1

从上面的示意图中,我们可以看出最基础的类型是 PIXI.DisplayObject 类,所有其它类都从它继承而来。这个类是将对象呈现到屏幕所必须需的元素。

当我说 展示对象 时,并非指 PIXI.DisplayObject 这个类。而当使用 PIXI.DisplayObject 这个说法时,却表示所有继承自它的对象。本质上讲,当我使用 展示对象 这一术语时,我指的是可以通过 pixi.js 呈现给屏幕的 任何对象

下一层是 PIXI.Container,它允许对象充当其他展示对象的 容器。我们在第一个教程中使用的 addChild() 方法是 PIXI.Container 这个类提供的实例方法,也可以通过 PIXI.SpritePIXI.TilingSpite 继承获得。

本质上讲,继承树中的每个类都是它继承的(父)类的 更特殊 版本(译者:面向对象的 具体化泛化 概念)。好的一点是我们可以使用继承来创建我们自己的自定义的展示对象。换而言之,我们可以为每个视差滚动器中的元素编写专用的类,并让 pixi.js 处理它们就像是处理其它展示对象一样。这给使我们封装代码更简单,代码也更多漂亮、整洁。

制作远景层展示对象

让我们开始制作远景层吧。

打开index.html文件,在 init() 函数中查找创建和设置图层的代码。这是你要找的东西:

var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");	
far = new PIXI.extras.TilingSprite(farTexture, 512, 256);
far.position.x = 0;
far.position.y = 0;
far.tilePosition.x = 0;
far.tilePosition.y = 0;
stage.addChild(far);

理想的情况是,我们可以创建一个代表远景层的类,并把大部分实现细节隐藏在类中。因此,我们希望找到以下代码,而不是上面的代码:

far = new Far();
stage.addChild(far);

代码量大幅减少了吧?另外,我认为它比我们原来的尝试更具可读性。

我们通过创建一个代表我们的滚动条远景层的名为 Far 的类来实现这一目标。在项目的根文件夹中创建一个新文件,并将其命名为 Far.js

现在定义一个名为 Far 的函数,它将表示我们类的构造函数:

(译者:原作者使用了 ES 5 和 prototype 来实现 JavaScript 中的继承,看起来可能没那么直观,可以参考我自己实现的 ES 6 版的代码)

function Far(texture, width, height) {
  PIXI.extras.TilingSprite.call(this, texture, width, height);
}

在构造函数下面添加以下行,然后保存文件:

Far.prototype = Object.create(PIXI.extras.TilingSprite.prototype);

上面的代码继承了 PIXI.extras.TilingSprite 类的功能。

构造函数是一种特殊类型的函数,用于创建类实例。在 JavaScript 中,构造函数的名称也用于指定类的名称(译者:ES 6 中的类有专门的 construct 方法)。

那么为什么 Far 类继承自 PIXI.TilingSprite 呢?好吧,如果你还记得第一个教程,我们使用 TilingSprite 实例来表示每个视差层。因此,在更具体化的类中使用这些功能是有必要的。本质上讲,我们所说的是:Far 类是 PIXI.extras.TilingSprite 的一个更特殊的版本。

因为 Far 类继承自 PIXI.extras.TilingSprite,所以我们要记得去初始化TilingSprite 类的功能。这是通过从构造函数中调用 TilingSprite 的构造函数来完成的。我高亮显示了以下代码行:

function Far(texture, width, height) {
  PIXI.extras.TilingSprite.call(this, texture, width, height); // 这一行
}

Far.prototype = Object.create(PIXI.extras.TilingSprite.prototype);

这样做是因为我们希望 Far 类继承 TilingSprite 的所有功能。由于 TilingSprite 需要将三个参数传递给它的构造函数,我们需要确保我们自己的类也接受这些参数并使用它们初始化瓦片精灵。以下是高亮显示参数的类:

// 注意 texture, width, height 三个参数
function Far(texture, width, height) {
  PIXI.extras.TilingSprite.call(this, texture, width, height);
}

Far.prototype = Object.create(PIXI.extras.TilingSprite.prototype);

我们还有一些额外的功能可以添加 Far 类中,但实际上已经可以开始将它集成到 index.html 页面中了。

实例化你的远景(Far)层类

返回你的 index.html 页面。

要使用 Far 类,你需要引用它的源文件。在页面正文顶部附近添加以下行:

<body onload="init();">
  <div align="center">
    <canvas id="game-canvas" width="512" height="384"></canvas>
  </div>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/4.0.0/pixi.min.js"></script>
  <script src="Far.js"></script><!--这里-->

现在向下滚动并删除以下行:

var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");
far = new PIXI.extras.TilingSprite(farTexture, 512, 256);  // 删除此行
far.position.x = 0;
far.position.y = 0;
far.tilePosition.x = 0;
far.tilePosition.y = 0;
stage.addChild(far);

替换成这样:

var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");
far = new Far(farTexture, 512, 256);  // 新行
far.position.x = 0;
far.position.y = 0;
far.tilePosition.x = 0;
far.tilePosition.y = 0;
stage.addChild(far);

好吧,我承认。目前这似乎并没有太大的改进,但我们现在可以开始在 Far 类中直接隐藏更多代码,让我们继续吧。

封装位置相关代码

index.html 中,我们当前设置了 far 层的 positiontilePosition 属性。让我们删除它,并将其封装在我们的 Far 类中。

var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");
far = new Far(farTexture, 512, 256);
far.position.x = 0;  // 删除
far.position.y = 0;  // 删除
far.tilePosition.x = 0;  // 删除
far.tilePosition.y = 0;  // 删除
stage.addChild(far);

保存更改并打开 Far.js 文件。现在直接在类的构造函数中设置图层的位置和tilePosition 属性:

function Far(texture, width, height) {
  PIXI.extras.TilingSprite.call(this, texture, width, height);
	
  this.position.x = 0;
  this.position.y = 0;
  this.tilePosition.x = 0;
  this.tilePosition.y = 0;
}

如果你不熟悉面向对象的 JavaScript 或面向对象编程,那么你可能会好奇 this 关键字在上面的代码中的用途是什么。基本上可以这么理解,它可以让你引用类的已创建实例。通过 this,我们可以引用该实例的所有 属性方法

因为 Far 类继承自 PIXI.extras.TilingSprite,它还具有 TilingSprite 的所有 属性方法,包括 positiontilePosition。要访问这些属性,我们只需使用this 关键字。这是再次设置图层 x 位置的代码:

this.position.x = 0;

还应注意,this 关键字还用于引用新添加到类中的属性或方法。

现在保存更改并在浏览器中测试代码。一切都应按预期运行。另外,请查看 Chrome 的 JavaScript 控制台,确保没有错误。

封装层的纹理

好的,我们应该从哪里开始呢。如果你回顾一下 index.html 页面,你应该看到代码好像开始变得更加简洁了:

var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");	
far = new Far(farTexture, 512, 256);
stage.addChild(far);

但仍有改进的余地。毕竟,如果我们可以直接在 Far 类中隐藏我们的定位代码,那么为什么我们不能把纹理的逻辑也放在 Far 类中呢?

切换到 Far.js 文件并在构造函数的开头添加一行以创建图层的纹理:

function Far(texture, width, height) {
  var texture = PIXI.Texture.fromImage("resources/bg-far.png"); // 添加
  PIXI.extras.TilingSprite.call(this, texture, width, height);

现在显式地将纹理的宽度和高度传递给 TilingSprite 的构造函数:

function Far(texture, width, height) {
  var texture = PIXI.Texture.fromImage("resources/bg-far.png");
  PIXI.extras.TilingSprite.call(this, texture, 512, 256); // 512, 256

由于我们现在直接在类中处理纹理,因此实际上不需要将纹理,宽度和高度参数传递给构造函数。删除所有三个参数并保存你的代码:

function Far(texture, width, height) { // 删除 texture, width, height

你的构造函数现在应该是这样:

function Far() {
  var texture = PIXI.Texture.fromImage("resources/bg-far.png");
  PIXI.extras.TilingSprite.call(this, texture, 512, 256);

  this.position.x = 0;
  this.position.y = 0;
  this.tilePosition.x = 0;
  this.tilePosition.y = 0;
}

剩下要做的就是返回到你的 index.html 文件并删除我们之前创建的纹理并传递给 far的构造函数:

var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");
far = new Far(farTexture, 512, 256);
stage.addChild(far);

改成这样:

far = new Far();
stage.addChild(far);

比以前简洁了,对吧?我们所有层的丑陋实现细节现在都安全地隐藏在 Far 类中。

保存 index.html 和 Far.js ,然后在 Chrome 中测试最新版本的代码。

同样的方法重构中间层

我花了一些时间引导你完成创建 Far类所需的步骤。该类继承自PIXI.extras.TilingSprite,其行为与任何其他 pixi.js 展示对象相同。虽然我们尚未完成,但我们将暂时停止一下并应用我们学到的知识来创建一个代表视差滚动器中的中间层(Mid)的类。

创建一个名为 Mid.js 的新文件,并开始向其添加以下代码:

function Mid() {
}

Mid.prototype = Object.create(PIXI.extras.TilingSprite.prototype);

同样在构造函数中,创建中间层的纹理并设置其定位属性:

function Mid() {
  var texture = PIXI.Texture.fromImage("resources/bg-mid.png");
  PIXI.extras.TilingSprite.call(this, texture, 512, 256);

  this.position.x = 0;
  this.position.y = 128;
  this.tilePosition.x = 0;
  this.tilePosition.y = 0;
}

Mid.prototype = Object.create(PIXI.extras.TilingSprite.prototype);

保存 Mid.js 文件,然后转到 index.html 并引用 Mid 类的源文件:

<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/4.0.0/pixi.min.js"></script>
<script src="Far.js"></script>
<script src="Mid.js"></script> <!--添加-->

完成后,向下滚动到 init() 函数并删除以下行:

far = new Far();
stage.addChild(far);

var midTexture = PIXI.Texture.fromImage("resources/bg-mid.png"); // 删除
mid = new PIXI.extras.TilingSprite(midTexture, 512, 256);// 删除
mid.position.x = 0;// 删除
mid.position.y = 128;// 删除
mid.tilePosition.x = 0;// 删除
mid.tilePosition.y = 0;// 删除
stage.addChild(mid);

用这一行代码替换它们:

far = new Far();
stage.addChild(far);

mid = new Mid(); // 此行
stage.addChild(mid);

保存 Mid.js 文件并在浏览器中测试最新版本。像往常一样,在运行时检查是否有 JavaScript 错误,并确保滚动器仍然按预期执行。

实现一个 update() 方法

我们已经对代码库进行了大量的重构,但仍然有一些事情可以做。返回 index.html 文件,查看动画主更新逻辑。它应该如下所示:

function update() {
  far.tilePosition.x -= 0.128;
  mid.tilePosition.x -= 0.64;

  renderer.render(stage);

  requestAnimationFrame(update);
}

update 方法中的前两行通过更新其 tilePosition 属性来滚动我们的图层。但是,我们的代码目前存在一些问题:通过直接更改 tilePosition 属性,我们将暴露 Mid 和 Far 类的内部 实现(译者:类的外部不应该知道类的具体实现细节,只需要控制类的行为)。这违背了面向对象的封装原则。

理想情况下,我们希望在类中隐藏具体细节。如果两个类只有一个实际为我们执行滚动的 update() 方法,那么我们的代码会更易读。换句话说,对于我们的主循环来说,这样似乎更合适:

function update() {
  far.update();
  mid.update();

  renderer.render(stage);

  requestAnimFrame(update);
}

值得庆幸的是,这样的改变是微不足道的。我们将向 Far 类和 Mid 类添加一个 update() 方法,每个类都会一点点的滚动。

从 Far 类开始,打开 Far.js 并向其添加以下方法:

Far.prototype = Object.create(PIXI.extras.TilingSprite.prototype);

Far.prototype.update = function() {
  this.tilePosition.x -= 0.128;
};

该方法(update)的主体应该看起来很熟悉。它只是将纹理的平铺位置移动 0.128 像素,这正是我们在 index.html 的主循环中所做的。

好的,保存更改并向Mid.js添加类似的方法:

Mid.prototype = Object.create(PIXI.extras.TilingSprite.prototype);

Mid.prototype.update = function() {
  this.tilePosition.x -= 0.64;
};

两个方法的唯一区别是 Mid 类中的 update() 方法的滚动量更多。

保存更改并返回 index.html。现在我们需要做的就是从主循环中调用每个层的 update() 方法。删除以下两行代码:

function update() {
  far.tilePosition.x -= 0.128; // 删除
  mid.tilePosition.x -= 0.64; // 删除

  renderer.render(stage);

  requestAnimFrame(update);
}

替换成:

function update() {
  far.update();
  mid.update();

  renderer.render(stage);

  requestAnimFrame(update);
}

保存更改并测试,保证 Chrome 中按预期正常运行。

停下来思考一下

虽然视差滚动器和以前一样表现正常,但我们实际上已对代码的整体架构进行了一些重大的更改。我们采用了更加面向对象的设计,利用继承创建了两个代表视差层的特殊展示对象。

能够编写特殊的展示对象是一个强大的概念,在许多情况下都能派上用场。我们的 Far 类和 Mid 类都像 pixi.js 支持的任何其他展示对象一样。下图说明了我们的两个特殊类位于 Pixi 展示对象类的继承结构中的位置。

ps-tut2-screenshot2

在继续之前,看看你的代码文件并确保我们迄今为止所做的一切都有意义。实际上并没有很多代码,但如果你是面向对象编程的新手,那么完全消化代码所表示的知识可能需要一些时间。

建立滚动器(Scroller)类

本教程开头概述的目标之一是将我们的视差滚动器包装到一个类中。现在我们已经编写了 Far 类和 Mid 类,现在我们写一个滚动器类。

这样的话我们就能够从 index.html 中删除 Mid 和 Far 实例,将它们封装在一个单独的对象中,以满足我们所有的滚动类需要实现的需求。

让我们写一个能够实现我们想法的类。创建一个名为 Scroller.js 的新 JavaScript 文件,并通过向其添加以下代码来定义名为 Scroller 的类:

function Scroller(stage) {
}

关于这个类,有两点值得注意。首先,它的构造函数需要引用我们的舞台(Pixi.Container)。其次,它不会继承任何东西。

与 Far 和 Mid 类不同,我们的 Scroller 类不是特殊的展示对象。相反,它将使用构造函数的 stage 参数添加我们的 远景层和中间层实例。(译者:Scroller 类只起到封装和控制作用,并不用继承任何 Pixi 中的类)

让我们先在类中添加远景层的实例:

function Scroller(stage) {
  this.far = new Far();
  stage.addChild(this.far);
}

第一行代码创建了 Far 类的实例。请注意,我们将实例存储在名为 far成员变量 中。

成员变量 是通过 this 关键字直接向类添加 属性 来创建的。成员变量具有在类实例的整个生命周期中持久化可见的优点,这意味着类的任何其他方法也可以访问它。

第二行将远景层实例添加到舞台。

现在让我们为中间层做同样的事情。将以下两行添加到构造函数中:

function Scroller(stage) {
  this.far = new Far();
  stage.addChild(this.far);

  this.mid = new Mid();
  stage.addChild(this.mid);
}

现在 Scroller 类中有两个成员变量:farmid。这是很有用,因为它允许我们从类中的任何其他方法中访问我们的视差层。这也很方便,因为我们确实需要添加一个额外的方法。它将用于更新两个层的位置。我们现在继续添加此方法(update):

function Scroller(stage) {
  this.far = new Far();
  stage.addChild(this.far);

  this.mid = new Mid();
  stage.addChild(this.mid);
}

Scroller.prototype.update = function() {
  this.far.update();
  this.mid.update();
};

还记得我们为 Mid 和 Far 类编写了 update() 方法吗?在我们的 Scroller 类自己的 update() 方法需要做的就是调用这些更新方法。

插入 Scroller 类

现在 Scroller 类可以表示我们的视差滚动器了,我们可以回到 index.html 页面并将其插入。

打开 index.html 并引用 Scroller.js:

<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/4.0.0/pixi.min.js"></script>
<script src="Far.js"></script>
<script src="Mid.js"></script>
<script src="Scroller.js"></script>

现在向下移动到 init() 函数并删除以下代码行:

function init() {
  stage = new PIXI.Stage(0x66FF99);
  renderer = PIXI.autoDetectRenderer(
    512,
    384,
    {view:document.getElementById("game-canvas")}
  );

  far = new Far(); // 删除
  stage.addChild(far); // 删除

  mid = new Mid(); // 删除
  stage.addChild(mid); // 删除

  requestAnimationFrame(update);
}

请记住,远景层和中间层现在都由 Scroller 类处理。因此,我们需要创建一个Scroller 实例来替换我们刚删除的行:

function init() {
  stage = new PIXI.Stage(0x66FF99);
  renderer = PIXI.autoDetectRenderer(
    512,
    384,
    {view:document.getElementById("game-canvas")}
  );

  scroller = new Scroller(stage); // 实例化 Scroller

  requestAnimationFrame(update);
}

另请注意,我们将 stage 引用传递给 Scroller 类的构造函数。这样做非常重要,因为 Scroller 类需要这个引用才能将 远景层和中间层添加到 展示列表 中。

现在需要做的就是在主循环中调用 scroller 的 update() 方法。首先,从主循环中删除以下两行:

function update() {
  far.update(); // 删除
  mid.update(); // 删除

  renderer.render(stage);

  requestAnimationFrame(update);
}

现在添加以下行来更新滚动器:

function update() {
  scroller.update(); // 添加

  renderer.render(stage);

  requestAnimationFrame(update);
}

保存更改并使用 Chrome 测试所有内容。一如既往地在 JavaScript 控制台中查找是否有错误,如果有,请仔细检查你的代码。

我们已经成功地重新构建了视差滚动,以便所有内容都包含在一个类中。如果你查看 index.html,你会发现我们已经隐藏了我们上次在第一篇教程中写的所有实现代码。

添加视口(viewport)

我们已经取得了巨大的进步,但还有一件事我们做。为了使我们的滚动条完整,需要添加 视口 的概念。将视口视为一个查看游戏地图的窗口。

你可能会问「我们不是已经有一个视口了吗?」是的,毕竟,当你在浏览器中运行代码时,我们只能看到在舞台边界内可以看到的内容。这是似乎就是一个视口了,但是我们还没有办法知道我们在游戏世界中 滚动了多远(译者:需要实现视口是因为后续会涉及到地图的概念,地图中游戏场景是有长度、距离的概念的,这就方便我们实现一些特殊场景,比如落箱子,障碍物等。因为不引用视口的概念游戏将是无限循环滚动的。这会导致计算距离变得很复杂,而且无法将地图设计成一种具体的抽象)。另外,如果我们可以简单地跳到某个位置并确切地看到我们的图层应该如何看起来,那不是很好吗?一旦我们添加了视口的概念并提供了设置其当前位置的方法,那么一切都将成为可能。

给 Scroller 类添加 setViewportX 方法

目前我们有一个 update() 方法,我们用它来连续滚动我们的视差层。可以使用一个名为 setViewportX() 的新方法替换它,我们可以用它来设置视口的水平位置。调用此方法将让我们随意定位我们的游戏地图。

让我们从 Scroller 类开始。

打开 Scroller.js 并删除现有的 update() 方法:

function Scroller(stage) {
  this.far = new Far();
  stage.addChild(this.far);

  this.mid = new Mid();
  stage.addChild(this.mid);
}

Scroller.prototype.update = function() { // 删除
  this.far.update();// 删除
  this.mid.update();// 删除
};// 删除

我们的 setViewportX() 方法非常简单。它期望将一个数字作为方法的 viewportX 参数传递,然后将该值传递给我们的每个层。显然,我们的图层都需要实现自己的 setViewportX() 方法。让我们继续吧,现在就去做吧。

给 Far 类添加 setViewportX 方法

我们首先删除类中的现有 update() 方法。打开 Far.js 并删除以下行:

function Far() {
  var texture = PIXI.Texture.fromImage("resources/bg-far.png");
  PIXI.extras.TilingSprite.call(this, texture, 512, 256);

  this.position.x = 0;
  this.position.y = 0;
  this.tilePosition.x = 0;
  this.tilePosition.y = 0;
}

Far.prototype = Object.create(PIXI.extras.TilingSprite.prototype);

Far.prototype.update = function() { // 删除
  this.tilePosition.x -= 0.128; // 删除
}; // 删除

我们需要能够跟踪视口的水平位置。为此,我们在类的构造函数中定义新的成员变量:

function Far() {
  var texture = PIXI.Texture.fromImage("resources/bg-far.png");
  PIXI.extras.TilingSprite.call(this, texture, 512, 256);

  this.position.x = 0;
  this.position.y = 0;
  this.tilePosition.x = 0;
  this.tilePosition.y = 0;

  this.viewportX = 0; // 新的成员变量
}

再添加一个类的 静态常量DELTA_X):

Far.prototype = Object.create(PIXI.extras.TilingSprite.prototype);

Far.DELTA_X = 0.128;

DELTA_X 常量的值看起来应该很熟悉。它是我们之前在每次调用 update() 时移动图层的 tilePosition 的像素数。显然,使用常量会使我们的代码更具可读性和可维护性,这就是我们选择使用常量的原因。基本上,每当我们的视口移动一个单元时,我们将使用常量将远景层移动 0.128 像素。所以现在让我们编写一个 setViewportX() 方法,添加以下内容:

Far.prototype = Object.create(PIXI.extras.TilingSprite.prototype);

Far.DELTA_X = 0.128;

Far.prototype.setViewportX = function(newViewportX) {
  var distanceTravelled = newViewportX - this.viewportX;
  this.viewportX = newViewportX;
  this.tilePosition.x -= (distanceTravelled * Far.DELTA_X);
};

上面的代码并不难理解。首先,我们计算自从上次调用 setViewportX() 以来的滚动的距离。然后视口的新水平位置存储在我们的 viewportX 成员变量中。最后,我们乘以 DELTA_X 常数,以确定将图层的瓦片移动了多远。

应该注意,我们的 x 位置代表视口窗口的左侧。在其他实现中,x 位置代表视口的中心也很常见。

保存最新版本的 Far.js

现在我们需要对 Mid 类进行相同的更改。

给 Mid 类添加 setViewportX 方法

Mid 类的代码几乎与 Far 类相同,所以我们能快速写出来。

打开 Mid.js 并删除其 update() 方法、并添加 setViewportX 方法:

function Mid() {
  var texture = PIXI.Texture.fromImage("resources/bg-mid.png");
  PIXI.extras.TilingSprite.call(this, texture, 512, 256);

  this.position.x = 0;
  this.position.y = 128;
  this.tilePosition.x = 0;
  this.tilePosition.y = 0;

  this.viewportX = 0;
}

Mid.prototype = Object.create(PIXI.extras.TilingSprite.prototype);

Mid.DELTA_X = 0.64;

Mid.prototype.setViewportX = function(newViewportX) {
  var distanceTravelled = newViewportX - this.viewportX;
  this.viewportX = newViewportX;
  this.tilePosition.x -= (distanceTravelled * Mid.DELTA_X);
};

这两个类之间的唯一区别是 Mid 类的 DELTA_X 常量值为 0.64,这是为了确保图层的滚动速度比 far 层快。保存更改。

测试视口

我们应该测试视口并确保设置其位置反映在我们的视差层中。首先,我们需要打开 index.html 并删除 scrolller 的 update() 方法:

function update() {
  scroller.update(); // 删除

  renderer.render(stage);

  requestAnimationFrame(update);
}

保存 index.html 文件并在浏览器中测试更改。你应该注意到你只能看见视差层,但都没有滚动。那是因为我们没有添加任何代码来真正更改视口的水平位置。目前它固定在默认的 x 位置 0。

在我们添加代码之前,我们可以在 Chrome 的 JavaScript 控制台中测试一下我们的滚动条的 setViewportX() 实际上是有效的。

scroller.setViewportX(50); /// 控制台中调用

JavaScript 控制台可以访问程序中的任何全局变量。因此,我们可以通过全局 scroller 变量访问滚动条并调用其 setViewportX() 方法。

你应该看到视差图层向左移动,这表示我们已成功重新定位了视口。

尝试将视口移动到 x = 7000 的位置 :

scroller.setViewportX(7000);

滚动视口

很明显,我们可以通过不断更新滚动器的视口位置来模拟游戏世界中的移动。我们可以在主循环中执行此操作,但是我们得够获取视口的当前水平位置。让我们继续为 Scroller 类添加一个新方法。

获取视口的位置

目前来讲我们的 Scroller 类并没存储当前视口位置,我们需要一个成员变量来实现它。

打开 Scroller.js 并在构造函数中定义以下成员变量:

function Scroller(stage) {
  this.far = new Far();
  stage.addChild(this.far);

  this.mid = new Mid();
  stage.addChild(this.mid);

  this.viewportX = 0; // 水平滚动量
}

并在 setViewportX() 方法中更新 viewportX 成员变量的值:

Scroller.prototype.setViewportX = function(viewportX) {
  this.viewportX = viewportX; // 更新
  this.far.setViewportX(viewportX);
  this.mid.setViewportX(viewportX);
};

完成后,我们可以编写一个 getViewportX() 方法,该方法将返回视口的当前位置:

Scroller.prototype.setViewportX = function(viewportX) {
  this.viewportX = viewportX;
  this.far.setViewportX(viewportX);
  this.mid.setViewportX(viewportX);
};
// 新方法
Scroller.prototype.getViewportX = function() {
  return this.viewportX;
};

保存你的代码。

更新主循环

现在要做的就是不断更新滚动器的视口位置。我们将在主循环中执行此操作。

打开 index.html,只需添加以下两行代码:

function update() {
  var newViewportX = scroller.getViewportX() + 5; // 添加
  scroller.setViewportX(newViewportX); // 添加
            
  renderer.render(stage);

  requestAnimationFrame(update);
}

第一行获取视口的 x 位置并将其增加 5 个单位。第二行采用新值并更新视口的当前 x 位置。从本质上讲,它会强制视口在每次调用主循环时滚动 5 个单位。

保存代码并在 Chrome 中运行它。你应该会再一次看到视差层向外滚动。试试不同的滚动速度看。例如,将视口增加 15 个单位而不是 5 个单位。

移动视口

让我们在 Scroller 类中再添加一个方法 moveViewportXBy,可以将视口从其当前位置移动指定的距离。这将让主循环看起来更加简洁。

在保存更改之前,打开 Scroller.js 并添加以下方法:

Scroller.prototype.getViewportX = function() {
  return this.viewportX;
};
// 添加新方法
Scroller.prototype.moveViewportXBy = function(units) {
  var newViewportX = this.viewportX + units;
  this.setViewportX(newViewportX);
};

就像我们之前做过的一样,这个新方法不难理解。它只是计算出视口的新位置然后调用类的 setViewportX() 方法来实际设置视口位置。

移回 index.html 并删除以下行:

function update() {
  var newViewportX = scroller.getViewportX() + 5; // 删除
  scroller.setViewportX(newViewportX); // 删除

  renderer.render(stage);

  requestAnimationFrame(update);
}

moveViewportXBy() 方法的单行替换它们:

function update() {
  scroller.moveViewportXBy(5); // 调用新的方法

  renderer.render(stage);

  requestAnimationFrame(update);
}

保存更改并在 Web 浏览器中测试更改。

回顾程序的主入口

本系列教程的第二部分即将结束。在我们完成之前,让我们回顾下 index.html 并做最后一个重构。

虽然我们已经完成了减少对全局变量的依赖的这样一项令人敬重的工作,但我们的 index.html 文件仍然有一些零散的全局变量。实际上,在大型应用程序中,将尽可能多的 JavaScript 与 HTML 页面分开也是一种很好的做法。虽然我们的 HTML 页面中没有多少 JavaScript,但我们可以做得更好。让我们把代码单独封装在一个与自己类名相同的文件中。这样,我们当前所依赖的全局变量将封装到类的成员变量中。

创建一个新文件并将其命名为 Main.js

为类创建构造函数,并将HTML页面的 init() 函数中的代码放入其中:

function Main() {
  this.stage = new PIXI.Container();
  this.renderer = PIXI.autoDetectRenderer(
    512,
    384,
    {view:document.getElementById("game-canvas")}
  );

  this.scroller = new Scroller(this.stage);

  requestAnimationFrame(this.update.bind(this));
}

注意上面使用 this 关键字。我们使用它来定义 stagerendererscroller 作为成员变量。

this 关键字也用于调用 JavaScript 函数 requestAnimationFrame()。代码大概是这样:

requestAnimationFrame(this.update.bind(this));

这里使用它来指定我们的类名为 update() 的方法(我们仍然要写这个方法)将在下一次重绘时调用。另外,还调用另一个你可能不熟悉的名为 bind() 的JavaScript 函数。它用来保证在调用 update() 时它正确地访问到 Main 类的实例。如果不用 bind()update() 方法将无法访问和使用任何 Main 类的成员变量。

好吧,让我们实际编写我们的类的 update() 方法。它将只包含我们原来 HTML 页面的 update() 函数中的代码:

Main.prototype.update = function() {
  this.scroller.moveViewportXBy(Main.SCROLL_SPEED);
  this.renderer.render(this.stage);
  requestAnimationFrame(this.update.bind(this));
};

我们再次使用了 this 关键字,而且利用了JavaScript 的 bind() 函数来确保我们的更新循环始终在正确的作用域下。

另外,请注意上面的代码在调用 scrolller 的 moveViewportXBy() 方法时使用了一个名为 SCROLL_SPEED 的常量。以前我们刚刚传递了一个硬编码值。我们实际可以将该常量添加到 Main 类中做为静态常量。在构造函数后面直接添加以下行:

  requestAnimationFrame(this.update.bind(this));
}

Main.SCROLL_SPEED = 5; // 添加

Main.prototype.update = function() {

好的,保存你的代码。

现在让我们打开 index.html 并删除以前的老代码。

删除以下行:

<!-- 全部删除 -->
<script>
  function init() {
    stage = new PIXI.Container();
    renderer = PIXI.autoDetectRenderer(
      512,
      384,
      {view:document.getElementById("game-canvas")}
    );

    scroller = new Scroller(stage);

    requestAnimationFrame(update);
  }

  function update() {
    scroller.moveViewportXBy(5);

    renderer.render(stage);

    requestAnimationFrame(update);
  }
</script>

用一个简单的实例化 Main 类的新 init() 函数代替:

<script>
  function init() {
    main = new Main();
  }
</script>

最后,通过添加以下行来引用到类 :

<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/4.0.0/pixi.min.js"></script>
<script src="Far.js"></script>
<script src="Mid.js"></script>
<script src="Scroller.js"></script>
<script src="Main.js"></script> <!-- 添加 -->

保存你的工作并测试仍在 Google Chrome 中运行的所有内容。

我们已经成功地将所有内容都移到了一个主应用程序类中,而 index.html中只剩下几行 JavaScript 来解决所有问题。

结语

哇哦!我们这一节涉及到了很多内容。虽然最终结果是相同的(我们仍然只有两个滚动视差层),但我希望你能看到重构代码的好处。现在一切都比干净了很多,我们有一个用于管理视差层的 Scroller 类。虽然这次我们的重点不是 pixi.js,但你至少应该体会到扩展 Pixi 展示对象类的好处。

下集预告…

所有这些变化都处于理想的位置,可以在此基础上开发第三个更复杂的视差层了。这个图层将作为游戏世界的 地图,并将由一系列 精灵 构建,而不是简单的重复纹理。我们将把目标放在 pixi.js 上,将涉及各种各样的好东西,包括精灵表(Spritesheet),纹理帧(texture frames)和对象池(object pooling)。

记得 GitHub 上提供了本系列和本系列教程的源代码哦。

第三部分 见。

使用 Pixi.js 构建一个视差滚动器(第一篇)

1970-01-01 08:00:00

翻译对照

原文: PART 1PART 2PART 3PART 4

译文: 第一篇第二篇第三篇・ 第四篇


关注 @chriscaleb

这个系列的教程已经更新到了 PixiJS v4 版本。

曾经玩过 CanabaltMonster Dash,好奇他们是如何构建一个滚动游戏地图的?在这个教程中我们将向「构建一个视差滚动器」迈出第一步,我们将使用 JavaScript 和 pixi.js 这个 2D 渲染引擎。

你将学到什么…

预备知识…

JavaScript 无处不在,由于浏览器的不断改善和大量的 JavaScript 库,我们真的开始看到 HTML5 游戏领域开发蓬勃发展。但是当有很多库可用的时候,选择合适的并非易事。

这个系列的教程将向你介绍 JavaScript 游戏开发的基础,我们会聚焦到 pixijs。它是一个支持 WebGL 和 HTML5 Canvas 的渲染框架。教程最后你将完成如下的一个视差滚动地图程序:

ps-tut1-screenshot1

点击上面的链接启动最终版的程序,这就是你将要完成的。注意它包含了三个视差层:一个远景(far)层,一个中间(mid)层,一个前景(foreground)层。在第一篇教程中我们将集中精力构建远景层和中间层。当然为了做到这一点教程必须涉及 pixi.js 的基础,当然如果你还是个 JavaScript 新手,这会是个很好的开始学习 HTML5 游戏编程的地方。

ps-tut1-screenshot1

开始之前,点击上面的链接预览下这篇教程中将做成的效果。你也可以从 github 上下载这个程序的 源代码

起步

为了完成编码,你需要一个代码编辑器,我将使用一个体验版的 sublime text,可以在 这里 下载到。

还需要一个浏览器来测试你的程序。任何现代浏览器都可以,我将用 Google Chrome,开发过程中将会涉及到一些开始者工具的使用。如果你还没有安装 Chrome,可以去 这里 下载。

为了测试你的程序,你还需要在你的开发机上安装一个 web 服务器。如果你用的是 Window,可以 安装 IIS,macOS 用户可以配置下系统默认的 Apache,如果你的系统是 OS X Mountain Lion 配置 web 服务器可以会比较麻烦,可以参考这个 教程

如果你有自己托管的 web 服务器,就可以直接上传所以文件来测试,或者如果你有一个 Dropbox 账号,你可以通过 DropPages 服务来托管你的文件。

web 服务器建好后,创建一个目录 parallax-scroller 如果你使用 Windows。你的 web 服务器根目录应该类似 C:\inetpub\parallax-scroller 。如果你使用 OS X 则应该是 /Users/your_user_name/Sitesyour_user_name 就是你电脑的用户名。

最后,在教程中我们将使用几个图片素材,不用你自己去找,我已经为你打包好了一个 zip 文件,下载并解压好你的 parallax-scroller 目录。

下面就是你的 parallax-scroller 文件夹的样子(Windows):

screenshot3

如果你用的是 Mac OS X 则应该如下图:

screenshot4

现在我们已经准备好开始写代码了,启动 Sublime Text 2 或者你最喜欢的编辑器。

创建画布

所有的 pixijs 项目都以一个 HTML 文件开始。在这里我们将创建一个 canvas 元素以及引入 pixi.js 库。canvas 元素表示HTML页面上将呈现滚动条的区域。

在你的项目根目录 parallax-scroller 下使用编辑器新建一个文件,命名为 index.html,并写入下面的代码:

<html>
  <head>
    <meta charset="UTF-8">
    <title>Parallax Scrolling Demo</title>
  </head>
  <body>
  </body>
</html>

现在看起来还非常奇怪,我们的 HTML 页面只有一个 <head><body> 元素。

现在让我们在页面上添加 HTML5 Canvas 元素,在 body 元素中添加如下的代码:

<body>
  <div align="center">
    <canvas id="game-canvas" width="512" height="384"></canvas>
  </div>
</body>

我们指定了 canvas 宽度 512 像素,高度 384 像素。这就是 pixi.js 为库渲染游戏的地方。注意我们给 canvas 了一个 id 属性,值为 game-canvas 这将使我们易于控制它,当 pixi.js 启动时也需要它

现在启动你的 web 服务器,在 浏览器中打开类似 http://localhost/parallax-scroller/index.html 或者 http://localhost/~your_user_name/parallax-scroller/index.html 的链接

你会发现并没有什么东西,我们来给 canvas 加点样式(style 标签):

<html>
  <head>
    <meta charset="UTF-8">
    <title>Endless Runner Game Demo</title>
    <style>
      body { background-color: #000000; }
      canvas { background-color: #222222; }
    </style>
  </head>
  <body>
  </body>
</html>

保存并刷新,你将会看见一个水平居中的灰色区域出现在页面上。

引入 pixi.js 类库

标签前面加入引用:

  <script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/4.0.0/pixi.min.js"></script>
</body>

Pixi.js 库文件托管在 CDN 上,URL 上的 4.0.0 表示库的版本号,你可以替换成其它的发行版。

添加程序的入口

给 body 元素添加 onload="init(); 表示页面加载完成时调用 init 方法。我们在 script 标签中添加一个 init 方法

<body onload="init();">
  <div align="center">
    <canvas id="game-canvas" width="512" height="384"></canvas>
  </div>
  <script src="pixi.js-master/bin/pixi.dev.js"></script>
  <script>
    function init() {
      console.log("init() successfully called.");
    }
  </script>
</body>

打开 Chrome Console,Windows 下按 F12,macOS 下按 Cmd + Opt + i。正常的话控制台就会有下面的输出:

> init() successfully called.

现在这个 init 方法做的事情还很少,最终它将做为入口负责你程序的调用。

初始化 pixi.js

我们在 init 方法中需要做下面两件事情:

我们先来创建一个舞台对象,如果你是个 Flash 开发者,你可能会对舞台的概念比较熟悉了。基本上舞台就是你游戏的图形内容呈现的地方。另一方面,渲染器控制舞台并且把游戏绘制到你的 HTML 页面中的 canvas 元素上,这样你的做的东西才最终呈现给了用户。

我们来创建一个舞台对象并将它关联到一个名字叫做 stage 的全局变量上。并且删除之前的 log 语句:

function init() {
  console.log("init() successfully called.");
  stage = new PIXI.Container();
}

pixi.js 的 API 包含了一些类和函数,并且被保存在 PIXI 模块命名空间下面。PIXI.Container 类用来表示一些 展示对象(display object) 的集合,同样也可以表示舞台这个根展示对象。

现在我们已经创建好了一个舞台,我们还需要一个渲染器。Pixi.js 支持两种渲染器:WebGL 和 HTML5 Canvas。你可以通过 PIXI.WebGLRenderer 或者 PIXI.CanvasRenderer 来分别创建它们各自的实例。然而,更好的做法是让 Pixi 为你判断浏览器自动检测并使用正确的渲染器。Pixi 默认会尝试使用 WebGL,如果不支持则回滚到 canvas。我们调用用 Pixi 的 PIXI.autoDetectRenderer() 函数来自动帮我们选择合适的渲染器。

function init() {		
  stage = new PIXI.Container();
  renderer = PIXI.autoDetectRenderer(
    512,
    384,
    {view:document.getElementById("game-canvas")}
  );
}

autoDetectRenderer() 函数需要传入渲染舞台上 cavnas 的宽度和高度,以及 cavnas 元素的引用,它返回 PIXI.WebGLRendererPIXI.CanvasRenderer 的实例,我们将其保存在名为 renderer 的全局变量中。

在上面的代码中,我们通过一个包含 view 属性的 JavaScript 对象来传递给 autoDetectRenderer 方法,表示 canvas 元素的引用。我们传递这个对象做为函数的第三个参数而不是直接传 canvas 对象的引用。

我们使用了硬编码的方式指定了宽,高,实际上可以直接通过 canvas 元素取得这两个值:

var width = document.getElementById("game-canvas").width;

渲染

为了能看到舞台上的内容,你得指导你的渲染器把舞台上的内容真正的绘制到 canvas 上。可以通过调用 renderer 的 render 方法,并传入舞台对象的引用来做到:

function init() {		
  stage = new PIXI.Container();
  renderer = PIXI.autoDetectRenderer(
    512,
    384,
    {view:document.getElementById("game-canvas")}
  );
  renderer.render(stage);
}

这将成功的把舞台渲染到浏览器中。当然我们还没有给舞台上添加任何东西,所以你还看不出来

为你的展示列表(display list)添加内容

现在你的舞台已经建成,让我们继续往上面添加一些实际的东西。毕竟我们不想一直只到一个黑色的窗口。

舞台上的东西被添加到一个 树型结构 的展示列表中。你的舞台扮演着这些展示列表的根元素的角色,同时展示列表也会有栈顺序的问题,这意味着有的对象展示在别的对象上面,这由他们被设计的索引深度决定。

有很多种类的 展示对象(display object) 可以被添加到 展示列表 中,最常见的是 PIXI.Sprite,它可以添加图片素材。

由于这个教程是关于创建视差滚动背景的,让我们来添加一个表示远景层的图片。 我们将以添加一行代码来加载 bg-far.png 文件,这个文件在 resources 目录中:

function init() {		
  stage = new PIXI.Container();
  renderer = PIXI.autoDetectRenderer(
    512,
    384,
    {view:document.getElementById("game-canvas")}
  );

  var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");

  renderer.render(stage);
}

图片素材被加载并保存为纹理(textures),这个纹理可以随后被符加到一个或者多个精灵上面。在上面的代码中我们调用了静态 PIXI.Texture.fromImage() 方法来创建一个PIXI.Texture 实例并将 bg-far.png 文件加载到其中。为了方便使用,我们将纹理引用赋值给名为 farTexture 的局部变量。

现在让我们创建一个精灵并将纹理附加到它上面。并将精灵定位在舞台的左上角:

function init() {		
  stage = new PIXI.Container();
  renderer = PIXI.autoDetectRenderer(
    512,
    384,
    {view:document.getElementById("game-canvas")}
  );

  var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");
  far = new PIXI.Sprite(farTexture);
  far.position.x = 0;
  far.position.y = 0;

  renderer.render(stage);
}

PIXI.Sprite 类用于创建精灵。它的构造函数将接收一个纹理的引用参数。我们使用了一个名为 far 的全局变量,并将新创建的 sprite 实例存储在其中。

聪明的你可能已经发现我们是如何使用 position 属性将精灵的 x 和 y 坐标设置到舞台的左上角的。舞台的坐标从左到右,从上到下,这意味着舞台的左上角位置为(0,0),右下角为(512,384)。

精灵有一个轴心点(pivot),它们可以来回旋转。轴心点也可以用来定位精灵。精灵的默认轴心点设置为左上角(0,0)。这就是为什么当我们的精灵定位在舞台的左上角时,我们将其位置设置为(0,0)。(译者:如果你将轴心点设置到正中央,那位置是(0,0)的精灵就会展示不全)

最后一步是将精灵添加到舞台上。这是使用 PIXI.Stage 类的(实例方法) addChild() 方法完成的。来看看怎么做吧:

  var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");
  far = new PIXI.Sprite(farTexture);
  far.position.x = 0;
  far.position.y = 0;
  stage.addChild(far);

  renderer.render(stage);
}

好的,保存你的代码并刷新浏览器。你可能已经满坏期望能看到背景图,但实际上可能看不到。为什么呢?在素材纹理被加载完成之前就渲染它可能并不能有任何效果。因为纹理加载是需要一小段时间的。

我们可以通过简单地等一段时间,然后再次调用 render 方法来解决这个问题。通过 Chrome 的控制台执行下面的代码即可:

renderer.render(stage);

由于我们之前声明的 renderer 是全局变量,所以你能在 console 中直接使用它。console 中可以使用任何 JavaScript 中声明的全局变量。

恭喜你!现在应该看到紧贴在屏幕顶部的背景图层了。

现在让我们继续添舞台上的中间层:

var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");
far = new PIXI.Sprite(farTexture);
far.position.x = 0;
far.position.y = 0;
stage.addChild(far);

var midTexture = PIXI.Texture.fromImage("resources/bg-mid.png");
mid = new PIXI.Sprite(midTexture);
mid.position.x = 0;
mid.position.y = 128;
stage.addChild(mid);

renderer.render(stage);

保存代码并刷新浏览器。你需要再次手动在 Chrome 控制台中调用渲染方法才能看到两个层:

renderer.render(stage);

因为中间层是在远景层 之后 加入的,所以它离我们更进,或者说有更高的层深度。也就是说每次调用 addChild() 方法添加的展示对象都会在上一次添加的对象之上。

我们在这一节的教程中将只会聚焦到远景层和中间层的展示,后面的几节中,我们会实现更复杂的前景层

主循环

现在我们有两个背景图层,我想我们可以尝试实现一些视差滚动,并且还可以找到一种渲染内容的方法,而不用从 JavaScript 控制台中手动执行。

为了避免疑惑,让我们快速解释下究竟是什么视差滚动。这是一种用于视频游戏的滚动技术,其中背景层在屏幕上移动的速度比前景层慢。这样做会在2D游戏中产生一种幻觉,并让玩家更有沉浸感(更真实)。

根据上面这些信息,我们可以将它应用于我们的两个精灵层,来生成一个水平视差滚动器,我们将背景层移动到屏幕上的速度比中间层慢一点。为了能让每个层都滚动,我们将创建一个主循环,我们可以不断改变每个层的位置。为了实现这一点,我们将使用 requestAnimationFrame() 的帮助,这是一个 JavaScript 函数,它能决定浏览器的最佳帧速率,然后在下一次重绘 canvas/stage 时调用指定的函数。我们还将使用这个主循环来 不断地 呈现我们的内容。

var midTexture = PIXI.Texture.fromImage("resources/bg-mid.png");
mid = new PIXI.Sprite(midTexture);
mid.position.x = 0;
mid.position.y = 128;
stage.addChild(mid);

renderer.render(stage);

requestAnimationFrame(update);

上面的代码,我们指定了一个 update 函数,如果你想连续调用 requestAnimationFrame() ,这将使得你的 update 方法每秒调用 60 次。或者通常称为每秒 60 帧(FPS)。

我们还没有 update 函数,但是在实现它之前,先删除渲染方法的调用,因为主循环中会处理这个逻辑。

var midTexture = PIXI.Texture.fromImage("resources/bg-mid.png");
mid = new PIXI.Sprite(midTexture);
mid.position.x = 0;
mid.position.y = 128;
stage.addChild(mid);

renderer.render(stage); // 删除它

requestAnimationFrame(update);

好吧,让我们来编写主循环并让它稍微改变两个层的位置,然后渲染舞台的内容,这样我们就可以看到每个帧重绘的差异。在 init() 函数之后直接添加 update() 函数:

function update() {
  far.position.x -= 0.128;
  mid.position.x -= 0.64;

  renderer.render(stage);

  requestAnimationFrame(update);
}

前两行代码更新了远景层和中间层精灵的水平位置。请注意,我们将远层向左移动0.128 像素,而我们将中间层向左移动 0.64 像素。要向左移动某些东西,我们得使用负值,而正值则会将其移动到右侧。另外请注意,我们将精灵移动了 小数 像素。 Pixi 的渲染器可以存储它们并使用子像素来处理它们位置。当你想要非常缓慢地在屏幕上轻推东西时,这是理想的选择。

在循环结束时,我们再次调用 requestAnimationFrame() 函数,以确保在下次再次绘制画布时自动再次调用 update()。正是它确保了我们的主循环被连续调用,从而能确保我们的视差层在屏幕上稳定移动。

ps-tut1-screenshot5

保存代码并刷新浏览器看看它长什么样子。你应该看到两个图层自动呈现在屏幕上。此外,当两个图层都在移动时,中间层实际上比远景层更快地移动,从而为场景提供深度感。但是你也应该发现有一个明显问题:当每个精灵移出屏幕的左侧时,它会向右边留下一个间隙。换句话说,两个图层的图形都没有循环,以给出连续滚动的错觉。还好,有一个解决方案。

使用瓦片(平铺)精灵

到目前为止,我们已经学会使用 PIXI.Sprite 类来表示展示列表中的对象。然而,pixi.js 还提供了几个其他 展示对象 以满足不同的需求。

如果你细心的观察一下 bg-far.png 和 bg-mid.png 的话,你应该注意到这两个图像都设计成可以水平平铺的(译:平铺就好比瓦片)。检查每个图像的左右边缘。你可以发现,最右边的边缘完美地匹配连接到最左边的边缘。换句话说,两个图像都被设计成无缝循环的。

因此,如果有一种方法可以简单地移动每个精灵的纹理以给出他们正在移动的错觉,而不是物理地移动我们的远景层和中间层精灵的位置,这不是很好吗?值得庆幸的是 pixi.js 提供了 PIXI.extras.TilingSprite 类,它就是用来做这个的。

所以,让我们对代码进行一些调整,来使用瓦片精灵。我们首先关注远景层。继续从建立函数中删除以下行:

var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");
far = new PIXI.Sprite(farTexture); // 删除它
far.position.x = 0;
far.position.y = 0;
stage.addChild(far);

替换成这样:

far = new PIXI.extras.TilingSprite(farTexture, 512, 256);

然后设置他们的位置:

far.tilePosition.x = 0;
far.tilePosition.y = 0;

在继续之前,让我们讨论 TilingSprite 类的构造函数及它的 tilePosition 属性。

和 Sprite 类的单个参数比较,您会注意到 TilingSprite 类的构造函数需要 3 个参数:

far = new PIXI.extras.TilingSprite(farTexture, 512, 256);

它的第一个参数与之前相同:纹理的引用。第二个和第三个参数分别表示瓦片精灵的宽度和高度。通常,将这两个参数设置为 纹理 的宽度和高度,比如 bg-far.png 为 512 x 256 像素。

我们又一次的硬编码的传入了两个宽高参数,可以通过下面的方法改善:

far = new PIXI.extras.TilingSprite(
  farTexture,
  farTexture.baseTexture.width,
  farTexture.baseTexture.height
);

我们还利用了平铺精灵的 tilePosition 属性,该属性用于偏移精灵纹理的位置。换句话说,通过调整偏移量,就可以水平或垂直地移动纹理,并使纹理环绕。本质上,你可以模拟滚动而无需实际更改精灵的位置。

我们将精灵的 tilePosition 属性默认设置为(0,0),这意味着远景层的外观在初始化的状态下没有变化:

far.tilePosition.x = 0;
far.tilePosition.y = 0;

剩下要做的就是通过不断更新精灵的 tilePosition 属性的水平偏移来模拟滚动。为此,我们将对 update() 函数进行更改。首先删除以下行:

function update() {
  far.position.x -= 0.128; // 删除它
  mid.position.x -= 0.64;

  renderer.render(stage);

  requestAnimationFrame(update);
}

替换成下面这样:

function update() {
  far.tilePosition.x -= 0.128;
  mid.position.x -= 0.64;

  renderer.render(stage);

  requestAnimationFrame(update);
}

现在保存 index.html 并再次刷新浏览器。你将看到远景层无缝滚动并一直重复着,这和我们的预期结果的一样。

好的,让我们继续为中间层做出相同的修改。以下是进行更改后 init() 函数:

function init() {
  stage = new PIXI.Container();
  renderer = PIXI.autoDetectRenderer(
    512,
    384,
    {view:document.getElementById("game-canvas")}
  );

  var farTexture = PIXI.Texture.fromImage("resources/bg-far.png");	
  far = new PIXI.extras.TilingSprite(farTexture, 512, 256);
  far.position.x = 0;
  far.position.y = 0;
  far.tilePosition.x = 0;
  far.tilePosition.y = 0;
  stage.addChild(far);

  var midTexture = PIXI.Texture.fromImage("resources/bg-mid.png");
  mid = new PIXI.extras.TilingSprite(midTexture, 512, 256);
  mid.position.x = 0;
  mid.position.y = 128;
  mid.tilePosition.x = 0;
  mid.tilePosition.y = 0;
  stage.addChild(mid);

  requestAnimationFrame(update);
}

现在继续对 update() 函数进行以下重构:

function update() {
  far.tilePosition.x -= 0.128;
  mid.tilePosition.x -= 0.64;

  renderer.render(stage);

  requestAnimationFrame(update);
}

保存并测试你的代码。这次你应该看到两个图层完全地滚动,同时环绕屏幕的左右边界。

结语

我们已经介绍了pixi.js 的一些基础知识,并了解了 PIXI.extras.TilingSprite 如何用于创建无限滚动图层的。我们还看到了如何使用 addChild() 将瓦片精灵堆叠在一起以产生真实的视差滚动。

我建议你继续尝试使用 Pixi 并查看它的文档和代码示例。两者都可以在 PixiJS 官方网站 上找到。

下集预告…

虽然我们有一个水平视差滚动器并且能运行起来,但它仍然有点简单。下次我们将介绍 视口世界 位置的概念,如果你想最终将你的卷轴添加到游戏中,这两个都很重要。它还将使我们处于添加前景层的良好位置,这将代表一个简单的平台游戏地图。

我们将花很多时间来重构现有的代码库。我们将采用更加面向对象的架构,摆脱目前对全局变量的依赖。在下一个教程结束时,所有滚动功能都将整齐地包含在一个类中。

我希望这个教程能帮助到你,也希望下次能在 第二部分 中见到你。

如何使用前端技术开发一个桌面跨端应用

1970-01-01 08:00:00

本文将会讲述一个完整的跨端桌面应用 代码画板 的构建,会涉及到整个软件开发流程,从开始的设计、编码、到最后产品成型、包装等。

本文不仅仅是一篇技术方面的专业文章,更会有很多产品方面的设计思想和将技术转换成生产力的思考,我将结合我自己的使用场景完全的讲解整个开发流程,当然涉及到设计方面的不一定具有普遍实用性,多数情况下都是我自己的一些喜好,我只关心自己的需求。

同时本文只从整体上讲思路,也会有个别的技术细节和常规套路,有兴趣的也可以直接去 github 上看 源码,文章会比较长,如果你只想知道一些拿来即用的「干货」,或许这篇文章并不是一个好的选择


一、定位需求

事情的起因是这样的,因为我们内部会有一些培训会议。会经常现场演示一些代码片段。比如说我们讲到 React 的时候会现场写一些组件,让大家能直观的感受到 React 的一些功能。

但是通常由于条件所所限,会议总会遇到一些意外。比如断网、投影分辨率低看不清文字等

起初我们用的是在线版的 codepen,但是感觉并不是那么好用。比如不能方便的修改字体大小,必须要在连网的情况下才能使用。另外它的 UI 设计不是很紧凑,通常我们展示代码的时候都投影是寸土寸金的,应该有一个简洁又不失功能的 UI 界面,能全屏展示…

于是我解决自己实现一个这样的轮子,那么大概的需求目标是有了:

  1. 离线可用
  2. 可以改变界面字体大小
  3. 更加简洁的 UI

二、整体设计

应用风格

代码画板解决的是 临时性 的一些 演示代码 的需求,所以它的本质属性是一个拿来即用的工具,它不应该有更复杂的功能,比如用户登录、代码片段的管理等。这些需求不是它要解决的。代码画板会提供一个简单的导出成 HTML 文件的功能,可以方便用户存储整个 HTML 文件。

既然是用来演示代码的,那么它的界面上应该只有两个东西,一个是 代码,一个就是 预览。像代码/控制台切换的功能都做成 tab 的形式,正常情况不需要让他们展示出来。像 codepen 那样把所有的代码编辑器功能都展示出来我认为是不对的。

codepen-demo

codepen 的界面给人感觉非常复杂,有很多功能点。当然我并不是在批评它,codepen 做为一个需要商业化运营的软件,势必会做的非常复杂,这样才能满足更多用户的需求。然而程序员写软件则可以完全按照自己的想法来,哪怕这个应用只给自己一个人用呢。

hello-code-sketch

桌面应用的设计

桌面应用的设计和 web 界面的设计还是有些细微区别的,同样的基于 electron 的应用,有的应用会让人感觉很「原生」,有的则一眼就能看出来是用 CSS 画的。我在设计代码画板的时候也尽量向原生靠近,避免产生落差感。比如禁用鼠标手型图标、在按钮或者非可选元素上禁止用户选择:

cursor: default;
user-select: none

因为实际上用户在使用一款应用的时候感性的因素影响占很大一部分,比如说有人不喜欢 electron 可能就是因为看到过 electron 里面嵌一个完整的 web 页面的操作,这就让人很反感。但是这不是 electron 的问题,而是应用设计者的问题。

应用标识的设计

说实话应用 logo 设计我也是业余水平,但是聊胜于无。既然水平不行,那就尽量设计的不难看就行了。可以参考一些好的设计。我用 sketch 画出 logo 的外形,sketch 有很多 macOS 的模块可以从网上下载下来,直接基于模板修改就可以了。

代码画板主要的界面是分割开的两个面板,左边是代码,右边是预览。所以我就大概画了一个形状

code-sketch-icon

这个 logo 有个问题就是线条过多,小尺寸的时候看不清楚。这个问题我暂时先忽略了,毕竟我还不是专业的,后续有好的创意可以再改

默认设置

代码画板也 不会有 设置界面,因为常用的设置都预定义好了,你不需要配置。顶多改变下代码字体的大小。使用编辑器的通用快捷键 command++/- 就解决了,或者插入三方库,直接使用编辑器的通用命令快捷键 command+p 调出。我们的思路就是把复杂的东西帮用户隐藏在后台,观众只需要关注演员台上的一分钟,而不必了解其它细节。

快捷键/可用性

由于代码画板的界面非常简单,在一些细小的必要功能就得添加一些快捷键。比如:切换 HTML/CSS/JS/Console 代码编辑器,我在每个 tab 上加了数字标号,暗示它是有顺序有快捷键的,而且这个切换方式和 Chrome tab 切换的逻辑一致,使用 command+数字 就可以实现,万一还是有人不会用的话,可以去看帮助文档。里面有所有的快捷键。

cs-tab

界面中间的分割条可以自定义拖动,双击重置平分界面

cs-spliter

刚开始的时候我把每个 tab 页签都分割成单独的面板,因为我觉得这个能拖动自定义面板大小的交互实在是太爽了,忍不住想去拖动它。但是后来想想,其实并没有必要,我们写代码时应该更专注于代码本身,如果只有两个面板,那么这个界面无论是认知还是使用起来就没有任何困难。

因为我们并不需要把一堆的功能的界面摔给用户,让他们自己去选择。

三、技术调研

实现控制台

通过使用流行的几款在线代码运行工具,我发现他们有一个共同的问题:控制台很难用。无法像 Chrome Console 那样展示任意类型的 JS 值。比如我想 log 一段嵌套的 JS 对象:

console.log({ a: { b: 1, c: { d: [1, 2, 3] } }})

大多数都展示成这样的:

[object Object] {
  a: [object Object] {
    b: 1
  }
}

Chrome 是这样的:

chrome-console

显然 Chrome 控制台中更直观。所以我们需要在前面的基础上加一个需求,即:实现一个基于 DOM 的日志展示界面(无限级联选择)

日志界面应该有下面这些功能:

  1. 展示任意 JS 类型的数据
  2. Primitive 类型的数据显示不同的颜色(number - 蓝色,string - 绿色)
  3. Object 类型默认折叠起来,点击按钮展示子级,属性过多需要展示缩略信息
  4. 数组前应该有长度标记
  5. 能展示 JS 运行时的报错 Error 信息

集成现代化的前端框工作流

现代化的前端写页面肯定不是 HTML/CSS/JS 一把梭了,至少应该有 Sass/Babel 的支持吧。

Sass 嵌套能让你少写很多选择器,当然 Less 也可以,但是在我们的这个应用里面区别不大,一般来说临时性的写一些代码很少会用到它们的细节功能。有 变量选择器 嵌套就够了

Babel 主要是解决了写 React 的问题,不用再安装一大堆的构建工具了,直接使用 UMDReact/ReactDOM 就可以了,而且 electron 内嵌的 chromium 也支持了 es6 的 class 写法,实际上 Babel 主要的目的还是用来转译 JSX

注意这里是有一个我认为是 刚性 的需求,比如临时忽然有个想法,或者想验证一段代码的话,正常情况是使用你的编辑器,新建 demo.html/demo.css/demo.js 等这些操作。但是这些动作太浪费时间了。有了代码画板以后,直接打应用就可以开始 coding 了,真正能做到开箱即用。

提高程序的扩展性

我们在写 demo 页面时通常是要引用很多第三方类库的,比如:Bootstrp/jQuery 等。我希望有一种方法可以方便的引用到这些库,直接把库文件的 link/script 标签插入到代码画板的 HTML 中,但是前端框架真的是太多了,又不能一个个去扣来写死到页面,就算是写死了随着框架版本的升级,可能就无法满足我们的需求。

以前写页面时经常会用到 bootcdn,无意中发现它提供了相关 API,可以直接拿来使用。接下来就得想办法让用户通过界面选择即可。

这个 API 有三层数据结构:库 - 版本 - 资源链接。这个功能要用界面来实现肯定会非常臃肿,界面上可能会放很多按钮。这就违背了「更简洁」的需求目标。

这时就得参考下我们经常使用的一些软件是如何解决 简洁性功能性 需求之间的矛盾问题的,我比较喜欢 Sublime Text 的一些界面设计,Command Palette 是我经常使用的,所以我决定再模拟一个 Command Palette 来实现插入第三方库的需求。而且重要的是这个 Command Palette 并不一定只用来实现这一个功能,或者后期会有一些别的功能需要添加,那这个 Command Palette 也是个很好的入口。

Command Palette

使用 electron 实现桌面应用

实现离线可用很多方法,比如使用 PWA 技术。但是 PWA 并不能给我带来一种原生应用的那种可靠感,相反 electron 刚好可以解决我的顾虑。同时它可以把你的应用打包成各个平台(macOS/Window/Linux)的原生应用。唯一的缺点就是安装包确实很大,一般来讲一个 electron 应用 安装完 至少要 100 多兆,不过我觉得还能接受,毕竟硬盘存储现在已经很廉价了。

有人可能对 electron 有抗拒,觉得 electron 应用太庞大、占系统资源什么的,不过我们做的这个应用并不需要常驻系统,临时性的使用一下,用完就关闭,正常写生产环境的代码肯定还是要换回 编辑器/IDE 的。同时因为 electron 降低了写桌面应用的门槛,确实有很多人把一个完整的在线的网页直接嵌进去,这也是有问题的。

electron 还有一个好处,因为它完全基于 HTML/CSS/JS 来实现 UI(可以使用 Chrome only 的一些新功能),那我们理论上可以在做桌面应用时顺手把 web 应用也做了。这就可以同时支持各个系统下的原生应用,并且有 web 在线版本。如果你不愿意使用原生应用,直接登录 web.code-sketch.com 使用在线版也没是一种选择。这样就使得我们的应用具有真正的 跨端 能力。

由于我们团队都使用了 macbook,所以我优先支持 macOS 的开发,另外 macOS Mojave 的系统级别的暗色主题我也比较喜欢,刚好实现支持 mojave 暗色主题这个需求也做上。

三、框架的选择

大方向确定了,像框架选择这个就简单了,基于 electron 的应用,需要你区分开 render/main process 来选择。

Render process

渲染进程 就是 electron 中界面的实现部分 ,一般来说就是一个 webview,选自己喜欢的框架即可。我使用 React 来实现界面。样式方面就不再使用框架了,因为我们的界面原则上没有复杂的元素,直接手写 CSS,300 行内基本上就可以解决问题。可能有人会觉得这不可能,实际情况是当你写样式只跑在 Chrome 里面的时候那感觉完全爽到飞起,CSS variable/flex/grid/calc/vh/rem 什么的都可以拿来用,实现一个功能的成本就降低了很多。

我使用 Codemirror 来做为主界面的代码编辑器,Monaco 也是一个好选择,但是它有点过于庞大了,而且如果想要自定义功能得自己写很多实现

主界面上的分割组件,使用了 React-split

Main process

主进程 就是 electron 应用程序的进程,主要的区别在于主进程中可以调用一些与原生操作系统交互的 API,比如对话框、系统风格主题等。并且有 node 的运行时,可以引用 NPM 包。当然渲染进程也可以有 node 支持,但是我建议渲染进程中就只放一些纯前端的逻辑,这样的话方便后期把应用分离成 web 版

因为我们要集成 Sass 编译功能,如果你也经历过 node-sass 的各种问题,那就应该果断选择 dart-sass — 使用 dart 实现,编译成了原生的 JS,没有依赖问题。dart-sass 我放在了 main process 中,因为我试过放在 render process 中会有各种报错。如果 web 端要实现这个功能就需要其它的解决办法了,比如做成一个 http 服务,让 web 调 http 服务。

Babel 的话我是放在了 渲染进程 中以 script 标签的方式调用,这样即使在 web 端 Babel 编译也是可用的。

总之如果你使用 electron 构建应用并且引入的第三方 NPM 包可以 支持 运行在客户端(浏览器)上,那就尽量把包放在渲染进程里面。

构建工具

我使用 Parcel 来构建 React 而不是 Create React App。后者用来写个小应用还可以,稍微大一点的,需要定制化一些东西你就得 eject 出来一大堆 webpack 配置文件,即便是我已经用 webpack 开发过几个项目了,但是说实话我还是没用会 webpack。写 webpack 配置的时间足够我自己写 npm script 来满足自己的需求了。

原生应用打

使用 electron-builder 来打包到平台原生应用,并且如果你有 Apple 开发者账号的话应用还可以提交到 AppStore 上去。

我目前的打包参数是这么配置的:

{
    "build": {
        "productName": "Code Sketch",
        "extends": null,
        "directories": { "output": "release" },
        "files": [
            "icon.icns",
            "main.js",
            "src/*.js",
            "所有需要的文件",
            "package.json",
            "node_modules/@babel",
            "node_modules/sass"
        ],
        "mac": {
            "icon": "icon.icns",
            "category": "public.app-category.productivity",
            "target": [ "dmg" ]
        }
    }
}

在你的 package.json 中添加 build 字段,productName, directories 这些按自己需要更改即可

四、分离开发环境

区分开开发环境

代码画板项目开过过程中涉及两个关键环境

  1. Parcel 构建环境(渲染进程):Parcel 可以为你提供一些现在 JS 的转译工作,因此你可以放心使用例如 ES6 的 JS 新特性
  2. Node.JS 运行环境(主进程+渲染进程):这个取决于你的 electron 版本中集成的是 node 版本,比如:Node 10 中就没有 ES Module,这意味着你如果要在 electron 主进程 是无法识别 import 这样的语句的,但是渲染进程由于你使用了 Parcel 编译,则无需考虑

这里温馨提示下:想要做到 electron 中的 渲染进程与主进程之间共享 JS 代码是非常困难的。就算是有办法也会特别的别扭,我的建议是尽量分离这两个进程中的代码,主进程主要做一些系统级别的 API 调用、事件分发等,业务逻辑尽量放在渲染进程中去做

如果非要共享,那建议单独做成一个 NPM 包分别做为主进程运行时依赖,和渲染进程的 Parcel 编译依赖,唯一的缺点就是实际上共享的代码会有两份。

渲染进程中调用 node API 可能会和 Parcel 打包工具冲突,一般在调用比如文件模块时,可以加上 window.require(‘fs’) 这样就可以兼容两个环境:

get ipc() {
    if (window.require) {
        return window.require('electron').ipcRenderer
    } else {
        return { on() {}, send() {}, sendToHost() {} }
    }
}
this.ipc.send('event', data)

这样的话你在浏览器端调试也不会产生报错。一般情况下,建议当你用渲染进程中的 JS 引用(require)包的时候都加上 window. 前缀就可以了。因为渲染进程中 window 是全局变量,调用 require 和调用 window.require 是等价的

开发流程

通常在测试的时候应用会调用一些 electron 内置的系统级别 API,这部分调用通常需要启动 electron,但是有时候只有渲染进程中 UI 界面上的改动,就不用再启动 electron 了,直接在浏览器里面测试即可。使用 Parcel 运行一个本地的服务,这样就可以在浏览器里面调试页面。整个开发过程需要两个命令(NPM Script):

启动 Parcel 编译服务器

"scripts": {
    "start": "./node_modules/.bin/parcel index.html -p 2044"
}

调试 electron 原生功能,注意设置 ELECTRON_START_URL

{
  "scripts": {
    "dev": "ELECTRON_START_URL=http://localhost:2044 yarn electron",
  }
}

技术难点

整个应用只有两个功能是需要我们自己写代码实现的:日志控制台,Sublime 命令行。我们分别来分析下这两个模块的难点。

日志控制台 的难点在于,我们需要打印任意类型的 JS 值。如果你对 JS 了解比较多的话自然会想到在 JS 中所有的东西都是 对象,即 Object,那么实际上当你想打印一个变量的时候,其实你只要把整个 Object 递归的遍历出来,然后做成一个无限级的下拉菜单就可以了。看起来大概想下面这样:

logger

Sublime 命令行 实际上开发起来还是比较简单的,使用 React 很简单就实现了功能,比较麻烦的是调用 bootcdn 的接口,过程中我发现接口返回数据量还是挺大的,有必要做上一层 localStorage 缓存,加快二次打开速度。

然而在使用的过程中你会发现当我想插入一个前端库需要很多操作,因为有 三级选择:库-版本-CDN 链接。虽然这个流程解决了 所有用户 的使用问题,但是却损害了 大部分 用户的体验。这个时候插入一个常用库的成本就很高了,所以我们就要加上一些快捷入口,来实现一键插入流行框架。

sublime-commend-p

我们写代码的思路是满足所有用户的使用需求,但是一个好产品的思路是先满足大多数用户(80%)的常规需求,再让其余的用户(20%)可以有选择

还有一个问题比较典型就是 React 这类框架在渲染大列表并且进行过滤(关键字查询)时性能的问题。注意这个性能问题 并不是 引入框架产生的,真正的原因是当你渲染的 HTML 节点数以千计的时候,批量操作 DOM 会使得 DOM Render 特别慢。

所以说当我们遇到性能问题的时候应该去查找问题的根源,而不是停留在框架使用上,实际上在 DOM 操作这个层面来讲 jQuery 提供了更多的性能优化,比如自身的缓存系统,以致于当你在使用的时候很难发现有性能问题。但是在类 React 框架中它们框架本身的重点并不在于解决你应用的性能问题。

类似我们上面讲到的,实际上 jQuery 帮助你屏蔽了很多舞台背后的东西,以致于你可以不用操心技术细节,你甚至可以把 jQuery 当做一个 产品 来使用,而类 React 框架你却要亲力亲为的用他来设计你的代码。

话题再转回性能问题。这时候需要我们去实现一个类似于 react-window 的功能,让列表元素根据滚动按需加载。这可能是一种通用的解决大列表加载的方案,但是我的解决方法更粗暴,因为我们的下拉过滤功能使用时用户只关注 最佳的匹配项 即可,后面匹配程度不高的项可以直接限制数量裁剪就行了嘛。很少有用户会一直滚动到下面去查找某个选项,如果有,那就说明我们这个匹配做的有问题。

slice() {
    const idx = (this.props.itemsPerPage || 50) * (this.state.activeFrame + 1)
    return this.props.items.slice(0, idx)
}

整个匹配筛选的状态大概是这样的:

this.state = {
    // 当前第N步选择
    step: 0,
    // 当前步骤数据
    items: [],
    // 是否显示
    active: false,
    // 当前选中项
    current: {},
    // 过滤关键字
    keyword: ''
}

这个 items 是当前步骤的所有数据,实际上我们这个组件是支持无限级的扩展的,那么我们通过组件的 props 传入所有层级的数据,然后持久存储在内存中。这个 所有层级的数据 是数据结构层面的,实际上它可能是通过异步接口获取的。

再来看看我们组件提供的所有 props

static defaultProps = {
    step: 0,
    active: false,
    data: [[]],    // 无限层级数据 [[], [], [], ...]
    // 数据的主键,用于钩子函数返回用户选择的结果集
    pk: 'id',

    autoFocus: true,
    activeCls: 'active',
    delay: 300,
    defaultSelected: 0,
    placeholder: '',
    async: false,
    alias: [],
    done: () => {}
}

这些数据都可以通过组件的 props 传入,这就意味着我们的这个组件才是真正的组件,别人也可以使用这样的功能,而他们并不用在意里面的细节,使用者只需要做好类似调用自己接口的这种业务逻辑。

组件的调用大概是这样的:

<CommandPalette step={0}
    key="CommandPalette"
    async={injectData}
    done={this.done.bind(this)}
    alias={alias}
    aliasClick={this.aliasClick.bind(this)}
    data={[ [], [], [] ]}
/>

async 这个 props 实际上是一个异步调用的钩子方法,它会回传给你组件上当前操作的相关数据状态,通过这些数据使用者就可以按自己的需求在不同的步骤上调用不同的方法

export const injectData = (step, item, results, cb) => {
    const API = 'https://api.bootcdn.cn/libraries'

    if (step === 0) {
        fetchData(`${API}.min.json`)
            .then(processLibraryData)
            .then(cb)
    } else if (step === 1) {
        // ...
    } else if (step === 2) {
        // ...
    }
}

另外关于 React 这里安利下自己翻译过的一个教程:React 模式,里面讲到 18 种短小精悍的 React 模式案例,非常简单易懂。

还有一个小窍门,我们在适配暗色主题时,传统的方法是直接写两套主题 CSS 代码,实际上我们要使用 CSS Variable 的话完全没必要生成两套了,背景色,字体都做成 CSS 变量,切换的时候只需要动态往页面插入更新过的 CSS 变量值即可

系统的一些参数想直接传给渲染进程也是比较麻烦的,我的做法是直接从主进程中的 loadUrl 方法上以 queryString 的方式传到渲染页面的 URL 上

const query = {
    theme: osTheme,
    app_path: app.getAppPath(),
    home_dir: app.getPath('home')
}

mainWindow.loadURL(process.env.ELECTRON_START_URL ? url.format({
    slashes: true,
    protocol: 'http:',
    hostname: 'localhost',
    port: 2044,
    query
}) : url.format({
    slashes: true,
    protocol: 'file:',
    pathname: path.resolve(app.getAppPath(), './dist/index.html'),
    query
}))

像程序运行时的一些参数(比如程序的根目录)也可以这么动态传过去,而且还有一个好处就是你甚至可以在渲染进程中测试与这些参数相关的功能。

五、宣传

demo 视频录制

我会把最终所有功能的使用方法录制成一个视频,万一有人不不想下载你的软件,只是要了解一下,这就是个很好的方法。我同时上传到了 Youtube 和 bilibili 这两个平台,其它的都有广告就没必要了

使用 Quicktime Player 即可,录制完使用 iMovie 转码成两倍速率的 mp4。如果你有兴趣还可以加上一段音乐什么的,让视频看起来更灵动

域名申请

域名是一个能让用户记住你产品的方法,如果你做的是一个成型的产品,那就一定要申请个域名。

我总是有这样的体验,有的时候看到一个非常不错的产品但由于当时没需求就忽略了,想起来或者突然有需求的时候缺记不起来名字叫什么了。

事实上代码画板最开始我给他起的名字是 code playground,这个更直观,但是名字太长,而且想用到的一些域名呀、Github 名、NPM 包都被注册了。

想来想去就换成了 code sketch,这和符合我们的设计初衷,即:一边是代码,一边是效果/草图

域名申请我一般会上 Godaddy,不用备案,.com 域名一年 ¥65.00,然后 DNS 服务器转到了 cloudflare,后续域名也会直接转到 cloudflare。因为据说以后在 cloudflare 上续费域名最便宜

网站搭建

宣传网站直接放在 github pages 上,做个自定义域即可,实在是太方便了。而且还有 SSL 支持,Github 真的是业界良心

web 版的代码画板,由于我们把渲染进程中的代码分离开发,所以直接把 parcel 打包出来的静态文件也做成 github pages 就可以了,爽歪歪,网站就等于一分钱不花了。后续做一些 web 版的增强功能时,可以做成前后端分离的 http 服务,这就是后话了

加入 Google analytics 代码

GA 可以让你了解网站的用户分布情况,清楚的知道网站访问的波动。比如说你把自己的链接放到某个网站上分享了,GA 里面就能看出来所有的推荐来源和波动,对于运营来说是非常有必要的

广告语

这个我还真想了好长时间,基于我对于代码画板的定义,我觉得它应该是一个我们有一个想法的时候需要快速去实现一个 demo 的地方,想来想去就定了一段看起来文邹邹的话,虽然听名字根本不知道它是干啥用的,但是没关系,程序员写东西就是要有个性,因为我的受众只有自己。

First place where the code was written... 一个你最初写代码的地方...

六、汇总使用到的库与工具

麻雀虽小,五脏俱全。我们来看下代码画板总共用到了多少东西:

七、结语总结

实事上我自己的开发这个应用的时候并没有严格按照这篇文章的顺序执行,而是想到一些实现一些,可能一个功能实现了后来觉得不好又干掉了,是不断的取舍、提炼的结果。

开发中我也不断的问自己这个功能是否有必要,如果可有可无那是不是可以去掉,这样才能使得用户更加关注于代码本身。

整个开发过程中自己实现的功能模块并不多,只有控制台、命令行窗口是自己实现的,其它的功能基本上都是靠社区现有的工具库来完成的,从这一点来说前端技术的生态还是挺好的。这使得当我从整体上构思一个产品时我不必在意那些细节,虽然过程中还是能感觉到前端工具/库的割裂感,但是整体而言还是向好的,毕竟工具对于开发者只是一种选择的。

八、引用

  1. https://github.com/keelii/code-sketch
  2. http://www.tweaknow.com/appicongenerator.php
  3. http://benschwarz.github.io/gallery-css/
  4. https://addyosmani.com/blog/react-window/
  5. https://github.com/keelii/reactpatterns.cn

开源一个自己写的代码画板

1970-01-01 08:00:00

代码画板 Code Sketch

最初写代码的地方...

功能

快捷键

截图

浅色主题

code-sketch-light

深色主题

code-sketch-dark

错误日志

log

控制台日志

error

开发

yarn or npm

yarn install
yarn start
yarn dev
# build release for mac
yarn release

支持

...

理解比平等更重要

1970-01-01 08:00:00

很早以前就想聊聊这个话题,但是一直没机会。昨天看完电影《无名之辈》后让我心里突然有了一点感想。这便记录下来

这部片子的剧情围绕着几小人物之间展开。一心想做协警的保安、一辈子想出人头地的劫匪、一个遭遇了车祸身体瘫痪的女孩、一心只想赚钱娶媳妇过日子普通男人…

这些角色在我们现实生活中实在是太普通了,而且都处在社会底层。每个人对背负着生活压力,每人个都过着悬崖勒马日子。为了生存和仅有的那点「并不远大的」理想坚持前行

中间那段胡广生痛骂电视台恶搞他们抢劫的桥段,让我感触很深。电视上那些转微博、发视频娱乐的网友不就是我们自己么,我们经常在网站上看到一些匪夷所思的新闻:一个没有智商的劫匪,拿上一杆枪,跑到手机店里面去抢手机。抢完了才发现是手机模型。这个事情在普通人看来真的是荒诞极了,简直是搞笑,大家都来消费这种新闻。却没有人真的去思考劫匪为什么会做这么愚蠢的事情

谁知道这个愚蠢的劫匪是一个没见过大世面的农村小伙,不识字,也没什么文化;一心想进城干大事,胸中充满了对生活的渴望。可是谁又知道他进城后遭受了些什么、被多少人排斥、鄙视。现实中处处碰壁,梦想被别人看的一文不值。这种落差感长久积存在心里,有一天终于爆发了,他开始报复社会,打架、抢劫

胡广生在电视上看见网友的恶搞时情绪完全崩溃,他撕心裂肺的哭着骂到:「老子要是犯法、你抓老子啊,关老子,你枪毙老子,老子认帐啊,为啥要恶搞老子、侮辱老子」

他是多么强大,强大到不怕犯法被枪毙;他又多么可怜,可怜没人尊重他,没人理解他


我们总是在网络上看到很多人在讲平等。各种阶层,各种角色

员工与老板的平等、父母和子女的平等、老师与学生的平等、女人和男人的平等、穷人与富人的平等。归结起来只有一种:强势者和弱势者的平等

但是实际上强势者强的本质是与生俱来的:老板有权利的优势、父母/老师有地位的优势、男人有生理的优势、富人有家族的优势

在这些角色之间要求平等是一件很「无理」的事情。举个简单的例子,人都是动物,都有动物的本性,一个高个子的人站在矮个子的人旁边,根本不用讲话,高个子的人会有一种原始的身体上的气势压迫感,矮个子的人会有原始的一种害怕与自我防卫的反应。当然人类是高级动物,人类可以通过语言、眼神的沟通来降低这种差异感。但是本质上讲这两种人的「不平等」是改变不了的,尤其是高个子的人,即使你有很好的修养、很高的受教育程度,这种与生俱来的东西也是没办法完全抹去的。事实上这两个人是没法平等的,你总不能要求别人和你长得一样高吧

为什么现在很多人要求所谓平等。是因为「平等」这个词可以「量化」。你有的东西我也必须有,你能做的事情我也可以做。这种感觉就像是我们开发了一个复杂的系统,我们需要有一个能实时检测到异常的监控系统,因为监控系统可以量化一个系统的好坏,可以给出一个简单的评价标准。但是监控并不能解决系统本身的好坏问题。与其花费大量精力在监控上面,不如多花时间来了解系统的核心逻辑,各个个部件之间的关系。这样的话即使系统出了问题也是在可预测范围内,监控也只是锦上添花的一个部件

回到主题上,那么为什么理解或者说同理心更重要呢。

这要从人的情感说起,其实人类是一种非常奇怪的生物,个体之间的差异让你很难理解对方的一些情绪变化。很多人都觉得很了解自己(我也是),实际上并不是的。我就有个非常困扰自己的问题,每当我唱歌非常投入的时候,唱到那么两句伤感的歌词时七窍都会有反应,我自己有的时候也在想,实际上我唱的歌词可能和我境遇半毛钱关系都没有,怎么会有这种反应呢。后来慢慢的我就理解了,因为人和人是不一样的,我们彼此有不同的年龄、性别、生活经历,同时在情感方面人又非常的奇怪,可能有的人会因为一句诗词而哭泣,可能有人会因为一段音乐而哭泣,有的人会因为一张泛黄的照片而哭泣,不同的人有不同的感受周围世界的能力,你通过视觉,别人通过听觉嗅觉。同时这些东西被记忆在大脑内的时候是立体的,全方位的。可能就是某个动作触发到了深藏在大脑中的一幅画面,瞬间你就会想起当时的天气,人物、气氛等,然后立马产生了真情的流露。从这一点来看人的大脑比起计算机简直高得不知道到哪里去了

以前我老看见电视上很多追星族,在演唱会上痛哭流涕,激动不已。我就很难理解,怎么会有这种人呢,真的是歌手唱的好听吗?可是一首歌又能有多好听呢。然后上大学时听了很多的摇滚乐,当时的状态就是:我觉得年轻时不喜欢摇滚乐是有病吧。到现在为止我也没去听过场摇滚演唱会,但是我可以想像到如果我去了,绝对也是和大部分人一样的那种摇头晃脑,疯狂呐喊,兴奋不已的状态。

所以说,看到一些匪夷所思的事情的时候不要急于去评价,或者下结论。试着去理解一下对方的境遇,万一自已经说错话了那也不要紧,承认自己的错误其实并没有那么困难,可能在你看来很简单的一句话就会让对方感受到整个世界的温柔

empathy

图:https://www.pexels.com/photo/photography-of-body-of-water-and-mountains-1544880/

名不正则言不顺,是这样的吗?

1970-01-01 08:00:00

名正言顺这个成语大家都知道,尤其在一些政治典故中经常被提及。今天就来聊聊这个话题

名正言顺的梗概出自于《论语·子路》中孔子和子路的一段对话:

子路曰:“卫君待子而为政,子将奚先?”

子曰:“必也正名乎!”

子路曰:“有是哉,子之迂也!奚其正?”

子曰:“野哉由也!君子于其所不知,盖阙如也。名不正,则言不顺;言不顺,则事不成;事不成,则礼乐不兴;礼乐不兴,则刑罚不中;刑罚不中,则民无所措手足。故君子名之必可言也,言之必可行也。君子于其言,无所苟而已矣。”

大概意思是说:

子路问孔子:“如果让你去卫国执政,你首先会做什么?”

孔子说:“一定要先找对名份!”

子路说:“是这样的吗?,你也太迂腐了吧,名份有什么用?”

孔子说:“你太粗野了!君子对于不懂的事情,一般都采取保留意见。名分不正当,说话就不合理;说话不合理,事情就办不成。事情办不成,法律就不能深入人心;法律不能深入人心,刑罚就不会公正;刑罚不公正,老百姓就会手足无措…”

正名的「名」就是名份的「名」。是古代官场政治制度下的一种阶级角色感,也是儒家思想核心部分 的角色感。如:君臣、父子、兄弟,各个角色应该做什么事情才是正确的,被提倡的

比如说《八佾》中开篇第一段:

孔子谓季氏:“八佾舞于庭,是可忍也,孰不可忍也?”

意思是说这个季氏用天子的舞蹈阵容在自己家里开 Party。「佾yì」就是舞蹈队伍中的列,天子八列、诸侯六列、大夫四列、士二列,每佾八人。孔子要从政得先正名,正名就要先把礼放在第一位,没有礼的话就会成为君不君、臣不臣、父不父、子不子的状态,这种「八佾舞于庭」的行为在孔子看来就是大逆不道,绝不能忍的事情

再如:

定公问:“君使臣,臣事君,如之何?”孔子对曰:“君使臣以礼,臣事君以忠。”

意思是 定公问孔子:“上级怎样对待下级?下级怎样对待上级?”孔子答:“上级尊重下级,下级忠于上级。”

其实古文是非常精炼的,有的句子我们根本不需要全部搞懂是什么意思,只看几个字眼儿就明白了,比如上面的:君 使 shǐ 臣,臣 shì 君。使 就是驱使、使唤; 就是为人做事、服侍的意思。我觉得这句话问之前就有了定位,根本不需要再回答

封建社会,人们对于名份的认同感处处可见。春秋时期的尊王攘夷,三国时期曹操奉天子以令不臣,刘备打江山也要号称汉景帝阁下玄孙,这些都是政治场上的所谓的正名

当这种思想蔓延到整个现代社会甚至是家庭里面的时候就更值得思考了

儒家思想中关于礼的部分我更倾向于认同它的角色感,这一点是古今通用的。现实生活中我们经常会有一种感觉:同样是一句话从某些人口中说出来就很合情合理,从另外一些人口中说出来则让人很难理解甚至气愤。就是因为中国人讲话是非常讲究角色和场合的

比如说:我(男的)看见一对男女聊天儿,谈到关于生活、工作的话题时,如果这个女的说:男人的一生很不容易,因为他们的一生都注定了要竞争,为钱、为权为了家人过得更好,他们们身上背负着很重的担子。

当我做为一个男的听到这段话的时候会觉得这话说的很好听,让人很舒服,同时也非常佩服女的身上的那种同理心。但如果这段话是从这个男的口中说出来的我只会觉得这男的矫情娘娘腔,甚至我会找很多理由来推翻他的观点

在男权社会下女人本来就是弱视的,社会能给予她们的不论是精神上的还是物质上的回报都没有男性多,相反男性天生就获得了更多的优待和资源,所以男性理应肩负更多的责任。事实上女性的一生更不容易,大部分的女性的一生会受到社会舆论家庭伦理方面的影响,以至于她们很少有自己的事业,健立家庭以后通常还有更多身体和精神上的付出,这使得她们几乎没机会做自己想做的事情

所以说这个角色非常重要,但问题是当你定义了自己是什么样的角色时,再去看问题,其实本来就是不客观的。最终可能会形成一种非黑即白的观点偏见

当我们今天再去回顾名正言顺的典故时,会发现即就是当下社会,大家还是有很多古时候遗留下来的碎片化的认识,这种认识会让人只相信权威的或者大众的观点,这种所谓名份上正确的角色传达出来的观点

社会上的名流、功成名就的人说出来的话就是可信的,匹夫布衣甚至都没有说话的权利。所以说名正言顺真的就是正确的吗?难道只有先正其名然后才能有话语权?就算是个小人物,他也有自己的人生境遇,也有喜怒哀乐。只要人家说话有理有据,那就理当受到尊重

名正言顺事实上讲的是一种政治上的正确,在国外也有很多这方面的讨论,比如说:Code of conduct,它就给出了一系列的准则,诸如人与人之间基本尊重、宗教、人种、道义方面的一些准则。这些准则在大多数人眼里是正确的,但是应不应该强加到其它不认同这个准则的人身上呢,或者说人们可不可以对里面的一些准则进行反驳

《鸦片战争》大家都知道,船坚炮利的英帝国都打到天津港口了,道光皇帝还是那种处理边疆叛乱,攘除蛮夷的态度,整个大众的的意识形态还停留在天下都是天子的,天朝之外全是名份不正确的蛮族夷地,皇帝发诏告还是「奉天承运皇帝诏曰」的口气。真正枪炮打到脸上的时候才意识到疼

ProseMirror 编辑器指南中文翻译版

1970-01-01 08:00:00

这个指南介绍了很多编辑器的设计理念,以及他们之间的关系。想完整的了解整个系统,建议按顺序阅读,或者至少阅读视图组件部分

简介 Introduction

ProseMirror 提供了一组工具和设计概念用来构建富文本编辑器,UI 的使用源于 WYSIWYG 的一些灵感,ProseMirror 试着屏蔽一些排版中的痛点

ProseMirror 的主要原则是:你的代码对于文档及其事件变更有完整的控制权。这个 文档 并不是原生的 HTML 文档,而是一个自定义的数据结构,这个数据结构包含了通过你明确允许应该被包含的元素,它们的关系也由你指定。所有的更新都会在一个你可以查看并做出响应的地方进行

核心的代码库并不是一个容易拿来就用的组合 — 我们会优先考虑 模块化可自定义 化胜过简单化,希望将来有用户会基于 ProseMirror 分发一个拿来就用的版本。因此,ProseMirror 更像是乐高积木而不是火柴盒拼的成的玩具车

总共有四个核心模块,任何编辑行为都需要用到它们,还有很多核心团队维护的扩展模块,类似于三方模块 — 它们提供有用的功能,但是你可以删除或者替换成其它实现了相同功能的模块

核心模块分别是:

此外,还有一些诸如 基础的编辑命令、快捷键绑定、恢复历史、输入宏、协作编辑,简单的文档骨架 的模块。Github prosemirror 组织 代码库中还有更多

事实上 ProseMirror 并没有分发一个独立的浏览器可以加载的脚本,这表示你可能需要一些模块 bunder 来配合使用它。模块 Bunder 就是一个工具,用来自动化查找你的脚本依赖,然后合并到一个单独文件中,使你能很容易的在 web 面页中使用。你可以阅读更多关于 bundling 的东西,比如:这里

我的第一个编辑器 My first editor

就像拼乐高积木一样,下面的代码可以创建一个最小化的编辑器:

import {schema} from "prosemirror-schema-basic"
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"

let state = EditorState.create({schema})
let view = new EditorView(document.body, {state})

ProseMirror 需要你为文档指定一个自己觉得合适的骨架(schema),所以上面的代码第一件事情就是引入一个基础骨架模块

接着这个骨架被用来创建一个状态,它将按骨架的定义生成一个空的文档,光标会在文档最开始的地方。最后创建了一个与状态关联的视图,并且插入到 document.body。这将会把状态的文档渲染成一个可编辑的 DOM 节点,并且一旦用户输入内容就会生成一个状态事务(transactions)

现在这个编辑器还没什么用处。比如说当你按下回车键时没有任何反应,因为核心库并不关心回车键应该用来做什么。我们马上就会谈到这一点

事务 Transactions

当用户输入或者与视图交互时,将会生成「状态事务」。这意味着它不仅仅是只修改文档并以这种方式隐式更新其状态。相反,每次更改都会触发一个事务的创建,该事务描述对状态所做的更改,而且它可以被应用于创建一个新状态,随后用这个新状态来更新视图

这些过程都会默认地在后台处理,但是你可以写一个插件来挂载进去,或者通过配置视图的参数。例如,下面的代码添加了一个 dispatchTransaction 属性(props),每当创建一个事务时都会调用它

// (省略了导入代码库)

let state = EditorState.create({schema})
let view = new EditorView(document.body, {
  state,
  dispatchTransaction(transaction) {
    console.log("Document size went from", transaction.before.content.size,
                "to", transaction.doc.content.size)
    let newState = view.state.apply(transaction)
    view.updateState(newState)
  }
})

每次状态变化都会经过 updateState,而且每个普通的编辑更新都将通过调度一个事务来触发

插件 Plugins

插件用于以各种方式扩展编辑器和编辑器状态的状态,有的会非常简单,比如 快捷键 插件 — 为键盘输入绑定具体动作;有的会比较复杂,比如 编辑历史 插件 — 通过观察事务并逆序存储来实现撤销历史记录,以防用户想要撤消它们

让我们为编辑器添加这两个插件来获取撤消(undo)/重做(redo)的功能:

// (Omitted repeated imports)
import {undo, redo, history} from "prosemirror-history"
import {keymap} from "prosemirror-keymap"

let state = EditorState.create({
  schema,
  plugins: [
    history(),
    keymap({"Mod-z": undo, "Mod-y": redo})
  ]
})
let view = new EditorView(document.body, {state})

当创建一个状态的时候插件就会被注册(因为插件需要访问状态事务),当这个开启了编辑历史状态的视图被创建时,你将可以通过按 Ctrl-Z 或者 Cmd-Z 来撤消最近的一次变更

命令 Commands

上面示例代码中的 undoredo 变量值是绑定到指定键位的一种叫做 命令 的特殊值。大多数编辑动作都是作为可绑定到键的命令编写的,它可以用来挂载到菜单栏,或者直接暴露给用户

prosemirror-commands 包提供了许多基本的编辑命令,其中一些是你可以需要用到的基本的快捷键,比如 回车,删除编辑器中指定的内容

// (Omitted repeated imports)
import {baseKeymap} from "prosemirror-commands"

let state = EditorState.create({
  schema,
  plugins: [
    history(),
    keymap({"Mod-z": undo, "Mod-y": redo}),
    keymap(baseKeymap)
  ]
})
let view = new EditorView(document.body, {state})

现在,你已经有了一个算得上可以使用的编辑器了

添加菜单,额外的为特定骨架指定的快捷键绑定 等功能,可以通过查看 prosemirror-example-setup 包了解更多。这个模块提供给你一组插件用来创建一个基础的编辑器,但是就像它的名字一样,只是个例子,并不是生产环境级别的代码库。真正的开发中,你可能会需要替换成自定义的代码来精确实现你想要的功能

内容 Content

一个状态的文档被挂在它的 doc 属性上。这是一个只读的数据结构,用各种级别的节点来表示文档,就像是浏览器的 DOM。一个简单的文档可能会由一个包含了两个「段落」节点,每个「段落」节点又包含一个「文本」节点的「文档」节点构成

当初始化一个状态时,你可以给它一个初始文档。这种情况下,schema 就变成非必传项了,因为 schame 可以从文档中获取

下面我们通过传入一个 DOM 元素 (ID 为 content)做为 DOM parser 的参数初始化一个状态,它将利用 schema 中的信息来解析出对应的节点:

import {DOMParser} from "prosemirror-model"
import {EditorState} from "prosemirror-state"
import {schema} from "prosemirror-schema-basic"

let content = document.getElementById("content")
let state = EditorState.create({
  doc: DOMParser.fromSchema(schema).parse(content)
})

文档 Documents

ProseMirror 定义了他自己的一种用来表示文档内容的数据结构。由于文档是构建所有编辑器的核心元素,了解它们的工作原理会对我们很有帮助

结构 Structure

一个 ProseMirror 文档就是一个节点,它包含一个片段,其中可以有一个或者多个子节点

这个和浏览器 DOM 非常相似,浏览器 DOM 是一个递归的树型结构。但是 ProseMirror 的不同点在于它存储内联元素的方式

在 HTML 中,一个段落的标记表示为一个树,就像这个:

<p>This is <strong>strong text with <em>emphasis</em></strong></p>

prosemirror-1

而在 ProseMirror 中,内联内容被建模为 扁平 的序列,标记作为元数据附加到节点

prosemirror-2

这更符合我们思考和使用此类文本的方式,这允许我们使用字符来表示位置而不是树的路径,这使得一些操作如分割、更改内容样式变得比维护树简单

这也意味着每个文档都只有一种合法的表现层。具有相同标记集的相邻文本节点总是组合在一起,并且不允许有空文本节点,标记出现的顺序由骨架指定

所以说一个 ProseMirror 文档就是一颗块级节点树,其中大多数叶子节点都是文本块,块级节点可以包含这些文本块儿。你也可以有一些空的叶子节点,比如水平分隔线或者视频元素

节点对象有很多属性,它们代表所处文档中的角色:

典型的「段落」节点是一个文本节点,而「引用块」可能是一个包含其它块级元素的元素。文本、硬换行(
)以及内联图片都是内联叶子节点,水平分隔线节点则是一种块级叶子节点

骨架允许你更准确地指定什么东西应该出现在什么位置,例如。即使一个节点允许块级内容,这并不表示他会允许所有的节点做为它的内容

标识与存储 Identity and persistence

另外一个 DOM 树和 ProseMirror 文档不同的地方是,对象表示节点的行为。在 DOM 中,节点是具有标识的可变的对象,这表示一个节点只能出现在一个父节点中,当节点更新的时候对象也会被修改

另外一方面,在 ProseMirror 中,节点仅仅是一些 ,和你想表示数字 3 一样,3 可以同时出现在很多数据结构中,它自己所处的部分与父元素没有连系,如果你给它加 1,你会得到一个新值 4,并且不用改变和原来 3 相关的任何东西

所以它是 ProseMirror 文档的一部分,它们不会更改,但是可以用作计算修改后的文档的起始值。它们也不知道自己处在什么数据结构当中,但可以是多个结构的一部分,甚至多次出现在一个结构中。它们是值,不是状态化的对象

这表示每当你更新文档,你将会获得一个新的文档值。文档值将共享所有子节点,并且不会修改原来的文档值,这使得它创建起来相对廉价

这有很多优点。它可以使两次更新之间的过程无效(严格控制更新的内容和过程),因为具有新文档的新状态可以瞬间转换。它还使得文档以某种数学方式推理变得更容易,相反的如果你的值不断的在后台发生变化这将会很难做到。这也使得协作编辑成为可能,并允许 ProseMirror 通过将最后一个绘制到屏幕的文档与当前文档进行比较来运行非常高效的 DOM 更新算法

由于此类节点使用了常规的 JavaScript 对象来表示,如果并且明确地冻结(freezing)其属性可能会影响到性能,实际上属性是 可以 改变的,但是并不建议你这么做,这将会导致程序中断,因为它们几乎总是在多个数据结构之间共享。所以要小心!请注意,这也适用于作为节点对象一部分的数组和普通对象,例如用于存储节点属性的对象或片段中子节点的数组(译注:意思是你最好不要更改类似的内部对象,给对象添加或者删除属性)

数据结构 Data structures

一个文档的对象看起来像这样:

prosemirror-3

每个节点都是一个 Node 类的实例。都有一个 type 做为标签,通过 type 可以知道节点的名字,节点的属性字段也是有效的等等。节点的类型(和标识类型)每骨架会创建一次,他们知道自己属于骨架的哪个部分

节点的内容存储在 Fragment 的一个实例中,它掌握着节点序列。即使节点没有或者不允许有内容,这个字段也会有值(共享的空 fragment)

一些节点类型允许添加属性,它些值被存储到每个节点上。比如,图片节点一般会使用属性来存储 alt 文本和图片的 URL

此外,内联节点包含一组活动标记 — 例如强调(emphasis)或链接(link)— 活动标记就是一组 Mark 实例

整个文档就是一个节点。文档内容表现为一个顶级节点的子节点。通常,它将包含一系列块节点,其中一些块节点可能是包含内联内容的文本块。但顶级节点本身也可以是文本块,这样的话文档就只包含内联内容

什么样的节点可以被允许,是由文档的骨架决定的。用代码的方式创建节点(而不是直接用基础骨架库),你必须通过骨架来实现,比如使用 nodetext 方法

import {schema} from "prosemirror-schema-basic"

// 如果需要的话可以把 null 参数替换成你想给节点添加的属性
let doc = schema.node("doc", null, [
  schema.node("paragraph", null, [schema.text("One.")]),
  schema.node("horizontal_rule"),
  schema.node("paragraph", null, [schema.text("Two!")])
])

索引 Indexing

ProseMirror 节点支持两种索引 — 它们可以看做是树,使用单个节点的偏移量,或者它们可以被视为一个扁平的标识(tokens)序列

第一种 允许你执行类似于对 DOM 与单个节点进行操作的交互,使用 child 方法和 childCount 直接访问子节点,编写扫描文档的递归函数(如果你只想查看所有节点,请使用 descendantsnodesBetween

第二种 当访问一个文档中指定的位置时更好用。它允许把文档任意位置表示为一个整数 — 即标记序列中的索引。这些标识并不做为对象在内存中 — 它们仅仅是用来计数的惯例 — 但是文档的树形状以及每个节点都知道它的大小,这使得按位置访问变的廉价(译注:类似于用下标访问扁平数组,而不是递归遍历嵌套结构的树)

因此,如果你有一个文档,当表示为 HTML 时,将如下所示:

<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>

标识序列以及位置下标,将会是这样:

0   1 2 3 4    5
 <p> O n e </p>

5            6   7 8 9 10    11   12            13
 <blockquote> <p> T w o <img> </p> </blockquote>

每个节点都有一个 nodeSize 属性,可以为告诉你整个节点的大小,你可以访问 .content.size 来获取节点 内容 的大小。注意,对于外部文档节点,打开和关闭标记不被视为文档的一部分(因为你无法将光标放在文档外面),因此文档的大小为 doc.content.size,而不是 doc.nodeSize

手动解释这样的位置会涉及相当多的计数操作。你可以调用 Node.resolve 来获取关于节点位置的更具有描述性的数据结构。这个数据结构将告诉你该位置的父节点是什么,它与父节点的偏移量是什么,父节点的祖先是什么,以及其他一些东西

注意区分子索引(每个 childCount),文档范围的位置和 node-local 偏移(有时在递归函数中用于表示当前正在处理的节点中的位置)

切片 Slices

为了处理一些诸如复制/粘贴和拖拽之类的操作,通过切片与文档进行通信是非常必要的,比如,两个位置之间的内容。这样的切片不同于整个节点或者片段,一些节点可能位于切片开始或者结束(译注:切片的开始位置可能在某个节点的中间)

例如,从一个段落的中间选择到下一个段落的中间,你所选择的切片中会有两个段落,那么切片的开始位置在第一个段落打开的地方,结束位置就在第二个段落打开的地方。然而如果你 node-select 一个段落,你就选择了一整个有关闭的节点。可能的情况是,如果将此类开放节点中的内容视为节点的完整内容,则会违反骨架约束(标签可能没关闭),因为某些所需节点落在切片之外

Slice 数据结构用于表示这样的切片。它存储一个 fragment 以及两侧的 节点打开深度(open depth)。你可以在节点上使用切片方法从文档外剪切切片

/*
0   1 2 3   4
 <p> a b </p>
4   5 6 7   8 
 <p> a b </p>
*/
// doc holds two paragraphs, containing text "a" and "b"
let slice1 = doc.slice(0, 3) // The first paragraph
/*
0|   1 2 3   |4
 <p> a b </p>
*/
console.log(slice1.openStart, slice1.openEnd) // → 0 0
let slice2 = doc.slice(1, 5) // From start of first paragraph
                             // to end of second
/*
0  1| 2 3   4
 <p> a b </p>
4    5|6 7   8 
 <p> a b </p>
*/
console.log(slice2.openStart, slice2.openEnd) // → 1 1

更改 Changing

由于节点和片段是持久存储的,因此 永远 不要修改它们。如果你有文档(或节点或片段)的句柄,那这个句柄引用的对象将保持不变(译注:这意味着并不能通过拿到的引用直接修改节点,因为这个节点的引用是不可变的值,当你想改变的时候节点可能已经成为历史)

大多数情况下,你将使用转换(transformations)来更新文档,而不必直接接触节点。这些也会留下更改记录,当文档是编辑器状态的一部分时,这是必要的

如果你确实想要「手动」派发更新的文档,那么 NodeFragment 类型上有一些辅助方法可用。要创建整个文档的更新版本,通常需要使用Node.replace,它用一个新的内容切片替换文档的给定范围。要少量地更新节点,可以使用 copy 方法,该方法使用新内容创建类似的节点。Fragments 还有各种更新方法,例如 replaceChildappend

骨架 Schemas

每个 ProseMirror 文档都有一个与之关联的骨架,骨架描述了文档中可能出现的节点类型以及它们嵌套的方式。例如,它可能会指定顶级节点可以包含一个或多个块,并且段落节点可以包含任意数量的内联节点,内联节点可以使用任何标记

有一个包含基础骨架的包,但 ProseMirror 的优点在于它允许你定义自己的骨架

节点类型 Node Types

文档中的每个节点都有一个类型,表示其语义和属性,正如其在编辑器中呈现的方式

定义骨架时,可以枚举其中可能出现的节点类型,并使用 spec 对象描述:

const trivialSchema = new Schema({
  nodes: {
    doc: {content: "paragraph+"},
    paragraph: {content: "text*"},
    text: {inline: true},
    /* ... and so on */
  }
})

这段代码定义了一个骨架,其中文档可能包含一个或多个段落,每个段落可以包含任意数量的文本

每个骨架必须至少定义顶级节点类型(默认为名称「doc」,但你可以设置),以及文本内容的(text)类型

注意内联的节点必须使用 inline 属性声明(但对于文本类型,根据定义是内联的,可以省略它)

内容表达式 Content Expressions

上面示例模式中的内容字段(paragraph+, text*)中的字符串称为内容表达式。它们控制子节点的哪些序列对此节点类型有效

例如 paragraph 表示「一个段落」, paragraph+ 表示「一个或者多个段落」。相似地,paragraph* 表示「零个或者更多个段落」, caption? 表示「零个或者一个说明文字」。你可以使用类正则的范围区间,比如 {2} 表示精确的两次,{1, 5} 表示 1~5 次,{2,} 表示 2~更多多次

可以组合这些表达式来创建序列,比如 heading paragraph+ 表示「首先是标题,然后是一个或多个段落」。你也可以使用管道运算符 | 表示两个表达式之间的选择,如 (paragraph|blockquote)+

某些元素类型组将在你的模式中出现多种类型 — 例如,你可能有一个「块」节点的概念,它可能出现在顶层但也嵌套在块引用内。你可以通过为节点规范提供 group 属性来创建节点组,然后在表达式中按名称引用该组

const groupSchema = new Schema({
  nodes: {
    doc: {content: "block+"},
    paragraph: {group: "block", content: "text*"},
    blockquote: {group: "block", content: "block+"},
    text: {}
  }
})

这里的 block+ 相当于 (paragraph | blockquote)+

建议在具有块内容的节点中始终需要至少一个子节点(例如上面示例中的 docblockquote),因为当节点为空时,浏览器将完全折叠节点,不方便编辑

节点在 or-表达式中的显示顺序非常重要。为非可选节点创建默认实例时,例如,为了确保在替换步骤后文档仍符合模式,将使用表达式中的第一个类型。如果这是一个组,则使用组中的第一个类型(由组成员在节点映射中显示的顺序确定)。如果我在示例骨架中切换 paragraphblockquote 的位置,编辑器尝试创建一个块节点时,你会得到堆栈溢出 - 它会创建一个 blockquote 节点,其内容至少需要一个块,因此它会尝试创建另一个 blockquote 作为内容, 等等

库中的节点操作函数并非每个都会检查它是否正在处理有效的内容 - 高层次的概念,例如 转换(transforms)会,但原始节点创建方法通常不会,而是负责为其调用者提供合理的输入。完全可以使用例如 NodeType.create 来创建具有无效内容的节点。对于在切片边缘 打开 的节点,这甚至是合理的事情。有一个单独的 createChecked 方法,以及一个事后 check 方法,可用于断言给定节点的内容是有效的。

标记 Marks

标记用于向内联内容添加额外样式或其他信息。骨架必须声明允许的所有标记类型。标记类型 是与 节点类型 非常相似的对象,用于标记标记对象并提供其他信息

默认情况下,具有内联内容的节点允许将骨架中定义的所有标记应用于其子项。你可以使用节点规范上的 marks 属性对其进行配置

这有一个简单的骨架,支持段落中文本的 strong 和 emphasis 标记,但不支持标题:

const markSchema = new Schema({
  nodes: {
    doc: {content: "block+"},
    paragraph: {group: "block", content: "text*", marks: "_"},
    heading: {group: "block", content: "text*", marks: ""},
    text: {inline: true}
  },
  marks: {
    strong: {},
    em: {}
  }
})

标记集被解释为以空格分隔的标记名称或标记组字符串 — _ 充当通配符,空字符串对应于空集

属性 Attributes

文档骨架还定义了每个节点或标记具有的属性。如果你的节点类型需要存储额外的信息,例如标题节点的级别,那就最好使用属性

属性集可以认为就是普通对象,具有预定义(每个节点或标记)属性集,包含任何 JSON 可序列化值。要指定它允许的属性,请使用节点中的可选 attrs 字段或标记规范

heading: {
  content: "text*",
  attrs: {level: {default: 1}}
}

在上面的骨架中,标题节点的每个实例都将具有 level 属性。如果不指定,则默认为 1

如果不指定属性默认值,在尝试创建此类节点又不传属性时将引发错误。在满足模式约束条件下进行转换或调用 createAndFill 时,也无法使用库生成此类节点并填充

序列化与解析 Serialization and Parsing

为了能够在浏览器中编辑它们,必须能够在浏览器 DOM 中表示文档节点。最简单的方法是使用 node spec 中的 toDOM 字段包含有关骨架中每个节点的 DOM 表示的信息

该字段应包含一个函数,当以节点作为参数调用时,该函数返回该节点的DOM 结构的描述。这可以是直接 DOM 节点或描述它的数组,例如:

const schema = new Schema({
  nodes: {
    doc: {content: "paragraph+"},
    paragraph: {
      content: "text*",
      toDOM(node) { return ["p", 0] }
    },
    text: {}
  }
})

表达式 [“p”, 0] 声明了一个段落会被渲染成 HTML <p> 标签。零是一个「孔」用来表示内容被渲染的地方,你还可以在标记名称后面包含具有 HTML 属性的对象,例如:["div", {class: "c"}, 0]。叶节点在其 DOM 表示中不需要「洞」,因为它们没有内容

Mark specs 允许类似于 toDOM 方法,但它们需要渲染成直接包装内容的单个标记,因此内容始终直接在返回的节点中,并且不需要指定「孔」

你可能经常需要从 DOM 数据中 解析 文档,例如,当用户将某些内容粘贴或拖动到编辑器中时。模型(Model) 模块具有相应的功能,建议你使用 parseDOM 属性直接在骨架中包含解析信息

这可以列出一个解析规则数组,它描述映射到给定节点或标记的 DOM 结构。例如,基础骨架具有以下表示 emphasis 标记:

parseDOM: [
  {tag: "em"},                 // Match <em> nodes
  {tag: "i"},                  // and <i> nodes
  {style: "font-style=italic"} // and inline 'font-style: italic'
]

解析规则中标记的值可以是 CSS 选择器,因此你也可以使用 div.myclass 之类的操作。同样,style 属性匹配内联 CSS 样式

当骨架包含 parseDOM 注解时,你可以使用 DOMParser.fromSchema 为其创建DOMParser 对象。这是由编辑器完成的,用于创建默认剪贴板解析器,但你也可以覆写它

文档还带有内置的 JSON 序列化格式。你可以在文档上调用 toJSON 以获取可以安全地传递给 JSON.stringify 的对象,并且骨架对象具有将此表示形式解析回文档的 nodeFromJSON 方法

扩展一个骨架 Extending a schema

传递给 Schema 构造函数的 nodes 和 marks 选项采用了 OrderedMap 对象以及纯 JavaScript 对象。骨架的 spec.nodes 和 spec.marks 属性始终是一组 OrderedMap,可以用作其它骨架的基础

此类映射支持许多方法以方便地创建更新版本。例如,你可以调用 schema.markSpec.remove(“blockquote”) 来派生一组没有 blockquote 节点的节点,然后可以将其作为新骨架的节点字段传入

schema-list 模块导出一个便捷方法,将这些模块导出的节点添加到节点集中

文档转换 Document transformations

转换是 ProseMirror 工作方式的核心。它们构成了事务的基础,转换使得历史追踪和协作编辑成为可能

为什么 Why?

为什么我们不能直接改变文档?或者至少创建一个新版本的文档,然后将其放入编辑器中?

有几个原因。一个是代码清晰度。不可变数据结构确实使得代码更简单。但是,转换系统的主要工作是留下更新的痕迹,以值的形式表示旧版本的文档到新版本所采取的各个步骤

撤消历史记录可以保存这些步骤并反转应用它们以便及时返回(ProseMirror 实现选择性撤消,这比仅回滚到先前状态更复杂)

协作编辑系统将这些步骤发送给其他编辑器,并在必要时重新排序,以便每个人最终都使用相同的文档

更一般地说,编辑器插件能够检查每个更改并对其进行响应是非常有用的,为了保持其自身状态与编辑器的其余状态保持一致

步骤 Steps

对文档的更新会分解为步骤(step)来描述一次更新。通常不需要你直接使用这些,但了解它们的工作方式很有用

例如使用 ReplaceStep 来替换一份文档,或者使用 AddMarkStep 来给指定的范围(Range)添加标记

可以将步骤应用于文档来成新文档

console.log(myDoc.toString()) // → p("hello")
// A step that deletes the content between positions 3 and 5
let step = new ReplaceStep(3, 5, Slice.empty)
let result = step.apply(myDoc)
console.log(result.doc.toString()) // → p("heo")

应用一个步骤是一件相对奇怪的过程 — 它没有做任何巧妙的事情,比如插入节点以保留骨架的约束,或者转换切片以使其适应约束。这意味着应用步骤可能会失败,例如,如果你尝试仅删除节点的打开标记,这会使标记失衡,这对你来说是毫无意义的。这就是为什么 apply 返回一个 result 对象的原因,result 对象包含一个新文档或者一个错误消息

你通常会使用工具函数来生成步骤,这样就不必担心细节

转换 Transforms

一个编辑动作可以产生一个或多个步骤(step)。处理一系列步骤最方便的方法是创建一个 Transform 对象(或者,如果你正在使用整个编辑器状态,则可以使用 Transaction,它是 Transform 的子类)

let tr = new Transform(myDoc)
tr.delete(5, 7) // Delete between position 5 and 7
tr.split(5)     // Split the parent node at position 5
console.log(tr.doc.toString()) // The modified document
console.log(tr.steps.length)   // → 2

大多数转换方法都返回转换本身,方便链式调用 tr.delete(5,7).split(5)

会有很多关于转换的方法可以使用,删除、替换、添加、删除标记,以及维护树型结构的方法如 分割、合并、包裹等

映射 Mapping

当你对文档进行更改时,指向该文档的指针可能会变成无效或并不是你想要的样子了。例如,如果插入一个字符,那么该字符后面的所有位置都会指向一个旧位置之前的标记。同样,如果删除文档中的所有内容,则指向该内容的所有位置现在都会失效

我们经常需要保留文档更改的位置,例如选区边界。为了解决这个问题,步骤可以为你提供一个字典映射,可以在应用该步骤之前和之后进行转换并且应用应该步骤

let step = new ReplaceStep(4, 6, Slice.empty) // Delete 4-5
let map = step.getMap()
console.log(map.map(8)) // → 6
console.log(map.map(2)) // → 2 (nothing changes before the change)

转换对象会自动对其中的步骤(step)累加一系列的字典,使用一种叫做 映射 的抽象,它收集了一系列的步骤字典来帮助你一次性映射它们

let tr = new Transaction(myDoc)
tr.split(10)    // split a node, +2 tokens at 10
tr.delete(2, 5) // -3 tokens at 2
console.log(tr.mapping.map(15)) // → 14
console.log(tr.mapping.map(6))  // → 3
console.log(tr.mapping.map(10)) // → 9

在某些情况下,并不是完全清楚应该将给定位置映射到什么位置。考虑上面示例的最后一行代码。位置 10 恰好指向我们分割节点的地方,插入两个标识。它应该被映射到插入内容后面还是前面?例子中明显是映射到插入内容后面

但是有时候你需要一些其它的行为,这是为什么 map 方法有第二个参数 bias 的原因,你可以设置成 -1 当内容被插入到前面时保持你的位置

console.log(tr.mapping.map(10, -1)) // → 7

Rebasing

当使用步骤和位置映射时,比如实现一个变更追踪的功能,或者给协作编辑符加一些功能,你可能就会遇到使用 rebase 步骤的场景

…(本小节译者并没有完全理解,暂不翻译,有兴趣可以参考原文

编辑器状态 The editor state

编辑的状态由什么构成?当然,你有自己的文档。还有当前的选区。例如当你需要禁用或启用一个标记但还没在该标记上输入内容时,需要有一种方法来存储当前标记集已更改的情况

一个 ProseMirror 的状态由三个主要的组件构成:doc, selectionstoredMarks

import {schema} from "prosemirror-schema-basic"
import {EditorState} from "prosemirror-state"

let state = EditorState.create({schema})
console.log(state.doc.toString()) // An empty paragraph
console.log(state.selection.from) // 1, the start of the paragraph

但是插件可能也有存储状态的需求 — 例如,撤消历史记录必须保留其变更记录。这就是为什么所有激活的插件也被存放在状态中的原因,并且这些插件可以用于存储自身状态的槽(slots)

选区 Selection

ProseMirror 支持几种选区类型(并允许三方代码定义新的选区类型)。选区就是 Selection 类的一个实例。就像文档和其它状态相关的值一样,它们是不可变的 — 想改变选区,就得创建一个新的选区对象让新的状态关联它

选区至少包含一个开始(.from)和一个结束(.to)做为当前文档的位置指针。许多选区还区分选区的 anchor(不可移动的)和 head(可移动的),因此这两个属性在每个选区对象上都存在

最常见的选区类型是文本选区(text selection),它用于常规的光标(当 anchorhead 一样时)或者选择的文本。文本选区两端必须是内联位置,比如 指向内联内容的节点

核心库同样也支持节点选区(node selections),当一个文档节点被选择,你就能得到它,比如,当你按下 ctrl 或者 cmd 键的同时再用鼠标点击一个节点。这样就会产生一个节点开始到结束的选区

事务 Transactions

在正常的编辑过程中,新状态将从它们之前的状态派生而来。但是某些情况例外,例如 你想要创建一个全新的状态来初化化一个新文档

通过将一个事务应用于现有状态,来生成新状然后进行状态更新。从概念上讲,它们只发生一次:给定旧状态和事务,为状态中的每个组件计算一个新值,并将它们放在一个新的状态值中

let tr = state.tr
console.log(tr.doc.content.size) // 25
tr.insertText("hello") // Replaces selection with 'hello'
let newState = state.apply(tr)
console.log(tr.doc.content.size) // 30

TransactionTransform 的子类,它继承了构建一个文档的方法,即 应用步骤到初始文档中。另外一点,事务会追踪选区和其它状态相关的组件,它提供了一些和选区相关的便捷方法,例如 replaceSelection

创建事务的最简单方法是使用编辑器状态对象上的 tr getter。这将基于当前状态创建一个空事务,然后你可以向其添加步骤和其他更新

默认情况下,旧选区通过每个步骤映射以生成新选区,但可以使用 setSelection 显式设置新选区

let tr = state.tr
console.log(tr.selection.from) // → 10
tr.delete(6, 8)
console.log(tr.selection.from) // → 8 (moved back)
tr.setSelection(TextSelection.create(tr.doc, 3))
console.log(tr.selection.from) // → 3

同样地,在文档或选区更改后会自动清除激活的标记集,并可使用 setStoredMarksensureMarks 方法设置

最后,scrollIntoView 方法可用于确保在下次绘制状态时,选区内容将滚动到视图中。大多情况下都需要执行此操作

Transform 方法一样,许多 Transaction 方法都会返回事务本身,以方便链式调用

插件 Plugins

当创建一个新状态时,你可以指定一个插件数组挂载到上面。这些插件将被应用在这个新状态以及它的派生状态上,这会影响到事务的应用以及基于这个状态的编辑器行为

插件是 Plugin 类的实例,可以用来实现很多功能,最简单的一个例子就是给编辑器视图添加一些属性,例如 处理某些事件。复杂一点的就如添加一个新的状态到编辑器并基于事务更新它

创建一个插件,你可以传入一个对象来指定它的行为:

let myPlugin = new Plugin({
  props: {
    handleKeyDown(view, event) {
      console.log("A key was pressed!")
      return false // We did not handle this
    }
  }
})

let state = EditorState.create({schema, plugins: [myPlugin]})

当一个插件需要他自己的状态槽时,可以定义一个 state 属性:

let transactionCounter = new Plugin({
  state: {
    init() { return 0 },
    apply(tr, value) { return value + 1 }
  }
})

function getTransactionCount(state) {
  return transactionCounter.getState(state)
}

示例中的插件定义了一个非常简单的状态,它只计算已经应用于某个状态的事务数。辅助函数使用插件的 getState 方法,该方法可用于从编辑器的状态对象中(插件作用域外)获取插件状态

因为编辑器状态是持久化(不可变)的对象,并且插件状态是该对象的一部分,所以插件状态值必须是不可变的。即需要更改,他们的 apply 方法必须返回一个新值,而不是更改旧值,并且其他代码是不可以更改它们的

可对于插件而言向事务添加一些额外信息是非常有用的。例如,撤销历史记录在执行实际撤消时会标记生成的事务,以便在插件可以识别到,而不仅仅是生成一个新事务,将它们添加到撤消堆栈,我们需要单独处理它,从撤消(undo)堆栈中删除顶部项,然后同时将此事务添加到重做(redo)堆栈

为此,事务允许附加元数据(metadata)。我们可以更新我们的事务计数器插件,过滤那些被标记过的事务,如下所示:

let transactionCounter = new Plugin({
  state: {
    init() { return 0 },
    apply(tr, value) {
      if (tr.getMeta(transactionCounter)) return value
      else return value + 1
    }
  }
})

function markAsUncounted(tr) {
  tr.setMeta(transactionCounter, true)
}

metadata 的属性键可以是字符串,但是要避免命名冲突,建议使用插件对象。有一些属性名库中已经定义过了,比如:addToHistory 可以设置成 false 表示事务不可以被撤消,当处理一个粘贴动作时,编辑器将在事务上设置 paste 属性为 true

视图组件 The view component

ProseMirror 编辑器视图是一个 UI 组件,它向用户显示编辑器状态,并允许它们进

核心视图组件使用的 编辑操作 的定义相当狭义 — 它用来直接处理与编辑器界面的交互,比如 输入、点击、复制、粘贴、拖拽,除此之外就没了。这表示核心视图组件并不支持一些高级一点的功能,像 菜单、快捷键绑定 等。想实现这类功能必须使用插件

可编辑的 DOM Editable DOM

浏览器允许我们指定 DOM 的某些部分是可编辑的,这具有允许我们可以在上面聚焦或者创建选区,并且可以输入内容。视图创建其文档的 DOM 展示(默认情况下使用模式的 toDOM 方法),并使其可编辑。当聚焦到可编辑的元素时,ProseMirror 确保 DOM 选区对应于编辑器状态中的选区

它还会注册很多我DOM在事件处理程序,并将事件转换为适当的事务。例如,粘贴时,粘贴的内容将被解析为 ProseMirror 文档切片,然后插入到文档中

很多事件也会按原生方式触发,然后才会由 ProseMirror 的数据模型重新解释。浏览器很擅长处理光标和选区等问题(当需要两向操作时则变得困难无比),所以大多数与光标相关的键和鼠标操作都由浏览器处理,之后 ProseMirror 会检查当前 DOM 选区对应着什么样的文本选区类型。如果该选区与当前选区不同,则通过调度一次事务来更新选区

甚至像输入这种动作通常都留给浏览器处理,因为干扰它往往会破坏诸如 拼写检查,某些手机上单词自动首字母大写,以及其它一些设配原生的功能。当浏览器更新 DOM 时,编辑器会注意到,并重新解析文档的更改的部分,并将差异转换为一次事务

数据流 Data flow

编辑器视图展示给定的编辑器状态,当发生某些事件时,它会创建一个事务并广播它。然后,这个事务通常用于创建新状态,该状态调用它的 updateState 方法将状态返回给视图

prosemirror-4

这样就创建了一个简单的循环数据流,在 JavaScript 的世界,通常会立即处理事件处理器(可以理解成命令式的触发事件后直接操作 DOM),后者往往会创建更复杂的数据流网络(数据流可能是双向的,不容易理解与维护)

由于事务是通过 dispatchTransaction 属性来调度的,所以拦载事务是可以的做到的,为了将这个循环数据流连接到一个更大的周期 — 如果你的整个应用程序使用这样的数据流模型,就像类似 Redux 的体系结构一样,你可以使 ProseMirror 的事务与主动作调度(main action-dispatching)周期集成起来,将 ProseMirror 的状态保留在你应用程序的 store

// The app's state
let appState = {
  editor: EditorState.create({schema}),
  score: 0
}

let view = new EditorView(document.body, {
  state: appState.editor,
  dispatchTransaction(transaction) {
    update({type: "EDITOR_TRANSACTION", transaction})
  }
})

// A crude app state update function, which takes an update object,
// updates the `appState`, and then refreshes the UI.
function update