jayzou

jayzou 查看完整档案

深圳编辑中山大学南方学院  |  计算机科学与技术 编辑Tencent  |  前端开发 编辑 jayzou.github.io/ 编辑
编辑

生鱼忧患,死鱼安乐

个人动态

jayzou 回答了问题 · 2020-10-05

js简写方式

this.cardDetail = {...row}

关注 4 回答 4

jayzou 赞了文章 · 2020-09-29

图说 ES Modules

原文:ES modules: A cartoon deep-dive, Lin Clark

ES modules(ESM) 是 JavaScript 官方的标准化模块系统。
然而,它在标准化的道路上已经花费了近 10 年的时间。

可喜的是,标准化之路马上就要完成了。等到 2018 年 5 月 Firefox 60 发布之后,所有的主流浏览器就都支持 ESM 了。同时,Node 模块工作小组也正在为 Node.js 添加 ESM 支持。为 WebAssembly 提供 ESM 集成的工作也正在如火如荼的进行。

许多 JS 开发者都知道,对 ESM 的讨论从开始至今一直都没停过。但是很少有人真正理解 ESM 的工作原理。

今天,让我们来梳理梳理 ESM 到底解决了什么问题,以及它跟其他模块系统之间有什么区别。

为何要模块化

说到 JS 编程,其实说的就是如何管理变量
编程的过程都是关于如何给变量赋值,要么直接赋值给变量,要么是把两个变量结合起来然后再把结果赋值给另一个变量。

01_variables.png

因为大部分代码都是关于改变变量的,所以如何组织这些变量就直接影响了编码质量,以及维护它们的成本。

如果代码中仅有少量的变量,那么组织起来其实是很简单的。
JS 本身就提供了一种方式帮你组织变量,称为函数作用域。因为函数作用域的缘故,一个函数无法访问另一个函数中定义的变量。

02_module_scope_01.png

这种方式是很有效的。它使得我们在写一个函数的时候,只需要考虑当前函数,而不必担心其它函数可能会改变当前函数的变量。
不过,它也有不好的地方。它会让我们很难在不同函数之间共享变量

如果我们想跟当前函数以外的函数共享变量要怎么办呢?一种通用的做法是把要共享的变量提升到上一层作用域,比如全局作用域。

在 jQuery 时代这种提升做法相当普遍。在我们加载任何 jQuery 插件之前,我们必须确保 jQuery 已经存在于全局作用域。

02_module_scope_02.png

这种做法也确实行之有效,但是也带来了令人烦恼的影响。
首先,所有的 <script> 必须以正确的顺序排列,开发者必须非常谨慎地确保没有任何一个脚本排列错误。

如果排列错了,那么在运行过程中,应用将会抛出错误。当函数在全局作用域寻找 jQuery 变量时,如果没有找到,那么它将会抛出异常错误,并且停止继续运行。

02_module_scope_03.png

这同时也使得代码的后期维护变得困难。
它会使得移除旧代码或者脚本标签变得充满不确定性。你根本不知道移除它会带来什么影响。代码之间的依赖是不透明的。任何函数都可能依赖全局作用域中的任何变量,以至于你也不知道哪个函数依赖哪个脚本。

其次,由于变量存在于全局作用域,所以任何代码都可以改变它
恶意的代码可能会故意改变全局变量,从而让你的代码做出危险行为。又或者,代码可能不是恶意的,但是却无意地改变了你期望的变量。

模块化的作用

模块化为你提供了一种更好的方式来组织变量和函数。你可以把相关的变量和函数放在一起组成一个模块。

这种组织方式会把函数和变量放在模块作用域中。模块中的函数可以通过模块作用域来共享变量。

不过,与函数作用域不同的是,模块作用域还提供了一种暴露变量给其他模块使用的方式。模块可以明确地指定哪些变量、类或函数对外暴露。

对外暴露的过程称为导出。一旦导出,其他模块就可以明确地声称它们依赖这些导出的变量、类或者函数。

02_module_scope_04.png

因为这是一种明确的关系,所以你可以很简单地辨别哪些代码能移除,哪些不能移除。

拥有了在模块之间导出和导入变量的能力之后,你就可以把代码分割成更小的、可以独立运行地代码块了。然后,你就可以像搭乐高积木一样,基于这些代码块,创建所有不同类型的应用。

由于模块化是非常有用的,所以历史上曾经多次尝试为 JS 添加模块化的功能。不过截止到目前,真正得到广泛使用的只有两个模块系统。
一个是 Node.js 使用的 CommonJS (CJS);另一个是 JS 规范的新模块系统 EcmaScript modules(ESM),Node.js 也正在添加对 ESM 的支持。

下面就让我们来深入理解下这个新的模块系统是如何工作的。

ESM 原理

当你在使用模块进行开发时,其实是在构建一张依赖关系图。不同模块之间的连线就代表了代码中的导入语句。

正是这些导入语句告诉浏览器或者 Node 该去加载哪些代码。
我们要做的是为依赖关系图指定一个入口文件。从这个入口文件开始,浏览器或者 Node 就会顺着导入语句找出所依赖的其他代码文件。

04_import_graph.png

但是呢,浏览器并不能直接使用这些代码文件。它需要解析所有的文件,并把它们变成一种称为模块记录(Module Record)的数据结构。只有这样,它才知道代码文件中到底发生了什么。

05_module_record.png

解析之后,还需要把模块记录变成一个模块实例。模块实例会把代码和状态结合起来。

所谓代码,基本上是一组指令集合。它就像是制作某样东西的配方,指导你该如何制作。
但是它本身并不能让你完成制作。你还需要一些原料,这样才可以按照这些指令完成制作。

所谓状态,它就是原料。具体点,状态是变量在任何时候的真实值。
当然,变量实际上就是内存地址的别名,内存才是正在存储值的地方。

所以,可以看出,模块实例中代码和状态的结合,就是指令集和变量值的结合

06_module_instance.png

对于模块而言,我们真正需要的是模块实例。
模块加载会从入口文件开始,最终生成完整的模块实例关系图。

对于 ESM ,这个过程包含三个阶段:

  1. 构建:查找,下载,然后把所有文件解析成模块记录。
  2. 实例化:为所有模块分配内存空间(此刻还没有填充值),然后依照导出、导入语句把模块指向对应的内存地址。这个过程称为链接(Linking)。
  3. 运行:运行代码,从而把内存空间填充为真实值。

07_3_phases.png

大家都说 ESM 是异步的。
因为它把整个过程分为了三个不同的阶段:加载、实例化和运行,并且这三个阶段是可以独立进行的。

这意味着,ESM 规范确实引入了一种异步方式,且这种异步方式在 CJS 中是没有的。
后面我们会详细说到为什么,然而在 CJS 中,一个模块及其依赖的加载、实例化和运行是一起顺序执行的,中间没有任何间断。

不过,这三个阶段本身是没必要异步化。它们可以同步执行,这取决于它是由谁来加载的。因为 ESM 标准并没有明确规范所有相关内容。实际上,这些工作分为两部分,并且分别是由不同的标准所规范的。

其中,ESM 标准 规范了如何把文件解析为模块记录,如何实例化和如何运行模块。但是它没有规范如何获取文件。

文件是由加载器来提取的,而加载器由另一个不同的标准所规范。对于浏览器来说,这个标准就是 HTML。但是你还可以根据所使用的平台使用不同的加载器。

07_loader_vs_es.png

加载器也同时控制着如何加载模块。它会调用 ESM 的方法,包括 ParseModuleModule.InstantiateModule.Evaluate 。它就像是控制着 JS 引擎的木偶。

08_loader_as_puppeteer.png

下面我们将更加详细地说明每一步。

构建

对于每个模块,在构建阶段会做三个处理:

  1. 确定要从哪里下载包含该模块的文件,也称为模块定位(Module Resolution)
  2. 提取文件,通过从 URL 下载或者从文件系统加载
  3. 解析文件为模块记录

下载模块

加载器负责定位文件并且提取。首先,它需要找到入口文件。在 HTML 中,你可以通过 <script> 标签来告诉加载器。

08_script_entry.png

但是,加载器要如何定位 main.js 直接依赖的模块呢?
这个时候导入语句就派上用场了。导入语句中有一部分称为模块定位符(Module Specifier),它会告诉加载器去哪定位模块。

09_module_specifier.png

对于模块定位符,有一点要注意的是:它们在浏览器和 Node 中会有不同的处理。每个平台都有自己的一套方式来解析模块定位符。这些方式称为模块定位算法,不同的平台会使用不同的模块定位算法。
当前,一些在 Node 中能工作模块定位符并不能在浏览器中工作,但是已经有一项工作正在解决这个问题

在这个问题被解决之前,浏览器只接受 URL 作为模块定位符。
它们会从 URL 加载模块文件。但是,这并不是在整个关系图上同时发生的。因为在解析完这个模块之前,你根本不知道它依赖哪些模块。而且在它下载完成之前,你也无法解析它。

这就意味着,我们必须一层层遍历依赖树,先解析文件,然后找出依赖,最后又定位并加载这些依赖,如此往复。

10_construction.png

如果主线程正在等待这些模块文件下载完成,许多其他任务将会堆积在任务队列中,造成阻塞。这是因为在浏览器中,下载会耗费大量的时间。

11_latency-500x270.png

而阻塞主线程会使得应用变得卡顿,影响用户体验。这是 ESM 标准把算法分成多个阶段的原因之一。将构建划分为一个独立阶段后,浏览器可以在进入同步的实例化过程之前下载文件然后理解模块关系图。

ESM 和 CJS 之间最主要的区别之一就是,ESM 把算法化为为多个阶段。

CJS 使用不同的算法是因为它从文件系统加载文件,这耗费的时间远远小于从网络上下载。因此 Node 在加载文件的时候可以阻塞主线程,而不造成太大影响。而且既然文件已经加载完成了,那么它就可以直接进行实例化和运行。所以在 CJS 中实例化和运行并不是两个相互独立的阶段。
这也意味着,你可以在返回模块实例之前,顺着整颗依赖树去逐一加载、实例化和运行每一个依赖。

12_cjs_require.png

CJS 的方式对 ESM 也有一些启发,这个后面会解释。
其中一个就是,在 Node 的 CJS 中,你可以在模块定位符中使用变量。因为已经执行了 require 之前的代码,所以模块定位符中的变量此刻是有值的,这样就可以进行模块定位的处理了。

但是对于 ESM,在运行任何代码之前,你首先需要建立整个模块依赖的关系图。也就是说,建立关系图时变量是还没有值的,因为代码都还没运行。

13_static_import.png

不过呢,有时候我们确实需要在模块定位符中使用变量。比如,你可能需要根据当前的状况加载不同的依赖。

为了在 ESM 中实现这种方式,人们已经提出了一个动态导入提案。该提案允许你可以使用类似 import(\`${path}/foo.js`)的导入语句。

这种方式实际上是把使用 import() 加载的文件当成了一个入口文件。动态导入的模块会开启一个全新的独立依赖关系树。

14dynamic_import_graph.png

不过有一点要注意的是,这两棵依赖关系树共有的模块会共享同一个模块实例。这是因为加载器会缓存模块实例。在特定的全局作用域中,每个模块只会有一个与之对应的模块实例。

这种方式有助于提高 JS 引擎的性能。例如,一个模块文件只会被下载一次,即使有多个模块依赖它。这也是缓存模块的原因之一,后面说到运行的时候会介绍另一个原因。

加载器使用模块映射(Module Map)来管理缓存。每个全局作用域都在一个单独的模块映射中跟踪其模块。

当加载器要从一个 URL 加载文件时,它会把 URL 记录到模块映射中,并把它标记为正在下载的文件。然后它会发出这个文件请求并继续开始获取下一个文件。

15_module_map.png

当其他模块也依赖这个文件的时候会发生什么呢?加载器会查找模块映射中的每一个 URL 。如果发现 URL 的状态为正在下载,则会跳过该 URL ,然后开始下一个依赖的处理。

不过,模块映射的作用并不仅仅是记录哪些文件已经下载。下面我们将会看到,模块映射也可以作为模块的缓存。

解析模块

至此,我们已经拿到了模块文件,我们需要把它解析为模块记录。
这有助于浏览器理解模块的不同部分。

25_file_to_module_record.png

一旦模块记录创建完成,它就会被记录在模块映射中。所以,后续任何时候再次请求这个模块时,加载器就可以直接从模块映射中获取该模块。

25_module_map.png

解析过程中有一个看似微不足道的细节,但是实际造成的影响却很大。那就是所有的模块都按照严格模式来解析的。
也还有其他的小细节,比如,关键字 await 在模块的最顶层是保留字, this 的值为 undefinded

这种不同的解析方式称为解析目标(Parse Goal)。如果按照不同的解析目标来解析相同的文件,会得到不同的结果。因此,在解析文件之前,必须清楚地知道所解析的文件类型是什么,不管它是不是一个模块文件。

在浏览器中,知道文件类型是很简单的。只需要在 <script> 脚本中添加 type="module" 属性即可。这告诉浏览器这个文件需要被解析为一个模块。而且,因为只有模块才能被导入,所以浏览器以此推测所有的导入也都是模块文件。

26_parse_goal.png

不过在 Node 中,我们并不使用 HTML 标签,所以也没办法通过 type 属性来辨别。社区提出一种解决办法是使用 .mjs 拓展名。使用该拓展名会告诉 Node 说“这是个模块文件”。你会看到大家正在讨论把这个作为解析目标。不过讨论仍在继续,所以目前仍不明确 Node 社区最终会采用哪种方式。

无论最终使用哪种方式,加载器都会决定是否把一个文件作为模块来解析。如果是模块,而且包含导入语句,那它会重新开始处理直至所有的文件都已提取和解析。

到这里,构建阶段差不多就完成了。在加载过程处理完成后,你已经从最开始只有一个入口文件,到现在得到了一堆模块记录。

27_construction.png

下一步会实例化这些模块并且把所有的实例链接起来。

实例化

正如前文所述,一个模块实例结合了代码和状态。状态存储在内存中,所以实例化的过程就是把所有值写入内存的过程。

首先,JS 引擎会创建一个模块环境记录(Module Environment Record)。它管理着模块记录的所有变量。然后,引擎会找出多有导出在内存中的地址。模块环境记录会跟踪每个导出对应于哪个内存地址。

这些内存地址此时还没有值,只有等到运行后它们才会被填充上实际值。有一点要注意,所有导出的函数声明都在这个阶段初始化,这会使得后面的运行阶段变得更加简单。

为了实例化模块关系图,引擎会采用深度优先的后序遍历方式
即,它会顺着关系图到达最底端没有任何依赖的模块,然后设置它们的导出。

30_live_bindings_01.png

最终,引擎会把模块下的所有依赖导出链接到当前模块。然后回到上一层把模块的导入链接起来。

30_live_bindings_02.png

这个过程跟 CJS 是不同的。在 CJS 中,整个导出对象在导出时都是值拷贝
即,所有的导出值都是拷贝值,而不是引用。
所以,如果导出模块内导出的值改变了,导入模块中导入的值也不会改变。

31_cjs_variable.png

相反,ESM 则使用称为实时绑定(Live Binding)的方式。导出和导入的模块都指向相同的内存地址(即值引用)。所以,当导出模块内导出的值改变后,导入模块中的值也实时改变了。

模块导出的值在任何时候都可以能发生改变,但是导入模块却不能改变它所导入的值,因为它是只读的。
举例来说,如果一个模块导入了一个对象,那么它只能改变该对象的属性,而不能改变对象本身。

30_live_bindings_04.png

ESM 采用这种实时绑定的原因是,引擎可以在不运行任何模块代码的情况下完成链接。后面会解释到,这对解决运行阶段的循环依赖问题也是有帮助的。

实例化阶段完成后,我们得到了所有模块实例,以及已完成链接的导入、导出值。

现在我们可以开始运行代码并且往内存空间内填充值了。

运行

最后一步是往已申请好的内存空间中填入真实值。JS 引擎通过运行顶层代码(函数外的代码)来完成填充。

除了填充值以外,运行代码也会引发一些副作用(Side Effect)。例如,一个模块可能会向服务器发起请求。

40_top_level_code.png

因为这些潜在副作用的存在,所以模块代码只能运行一次
前面我们看到,实例化阶段中发生的链接可以多次进行,并且每次的结果都一样。但是,如果运行阶段进行多次的话,则可能会每次都得到不一样的结果。

这正是为什么会使用模块映射的原因之一。模块映射会以 URL 为索引来缓存模块,以确保每个模块只有一个模块记录。这保证了每个模块只会运行一次。跟实例化一样,运行阶段也采用深度优先的后序遍历方式。

那对于前面谈到的循环依赖会怎么处理呢?

循环依赖会使得依赖关系图中出现一个依赖环,即你依赖我,我也依赖你。通常来说,这个环会非常大。不过,为了解释好这个问题,这里我们举例一个简单的循环依赖。

41_cjs_cycle.png

首先来看下这种情况在 CJS 中会发生什么。
最开始时,main 模块会运行 require 语句。紧接着,会去加载 counter 模块。

41_cyclic_graph.png

counter 模块会试图去访问导出对象的 message 。不过,由于 main 模块中还没运行到 message 处,所以此时得到的 messageundefined。JS 引擎会为本地变量分配空间并把值设为 undefined

42_cjs_variable_2.png

运行阶段继续往下执行,直到 counter 模块顶层代码的末尾处。我们想知道,当 counter 模块运行结束后,message 是否会得到真实值,所以我们设置了一个超时定时器。之后运行阶段便返回到 main.js 中。

43_cjs_cycle.png

这时,message 将会被初始化并添加到内存中。但是这个 messagecounter 模块中的 message 之间并没有任何关联关系,所以 counter 模块中的 message 仍然为 undefined

44_cjs_variable_2.png

如果导出值采用的是实时绑定方式,那么 counter 模块最终会得到真实的 message 值。当超时定时器开始计时时,main.js 的运行就已经完成并设置了 message 值。

支持循环依赖是 ESM 设计之初就考虑到的一大原因。也正是这种分段设计使其成为可能。

ESM 的当前状态

等到 2018 年 5 月 Firefox 60 发布后,所有的主流浏览器就都默认支持 ESM 了。Node 也正在添加 ESM 支持,为此还成立了工作小组来专门研究 CJS 和 ESM 之间的兼容性问题。

所以,在未来你可以直接在 <script> 标签中使用 type="module",并且在代码中使用 importexport
同时,更多的模块功能也正在研究中。
比如动态导入提案已经处于 Stage 3 状态;import.meta也被提出以便 Node.js 对 ESM 的支持;模块定位提案 也致力于解决浏览器和 Node.js 之间的差异。

相信在不久的未来,跟模块一起玩耍将会变成一件更加愉快的事!

查看原文

赞 126 收藏 232 评论 12

jayzou 赞了文章 · 2020-09-29

聊聊 ESM、Bundle 、Bundleless 、Vite 、Snowpack

前言

一切要都要从打包构建说起。

当下我们很多项目都是基于 webpack 构建的, 主要用于:

  • 本地开发
  • 打包上线

首先,webpack 是一个伟大的工具。

经过不断的完善,webpack 以及周边的各种轮子已经能很好的满足我们的日常开发需求。

我们都知道,webpack 具备将各类资源打包整合在一起,形成 bundle 的能力。

可是,当资源越来越多时,打包的时间也将越来越长。

一个中大型的项目, 启动构建的时间能达到数分钟之久。

拿我的项目为例, 初次构建大概需要三分钟, 而且这个时间会随着系统的迭代越来越长。

相信不少同学也都遇到过类似的问题。 打包时间太久,这是一个让人很难受的事情。

那有没有什么办法来解决呢?

当然是有的。

这就是今天的主角 ESM, 以及以它为基础的各类构建工具, 比如:

  1. Snowpack
  2. Vite
  3. Parcel

等等。

今天,我们就这个话题展开讨论, 希望能给大家一些其发和帮助。

文章较长,提供一个传送门:

  1. 什么是 ESM
  2. ESM 是如何工作的
  3. Bundle & Bundleless
  4. 实现一个乞丐版 Vite
  5. Snowpack & 实践
  6. bundleless 模式在实际开发中存在的一些问题
  7. 结论

正文

什么是 ESM

ESM 是理论基础, 我们都需要了解。

「 ESM 」 全称 ECMAScript modules,基本主流的浏览器版本都以已经支持。

image.png

ESM 是如何工作的

image.png

当使用ESM 模式时, 浏览器会构建一个依赖关系图。不同依赖项之间的连接来自你使用的导入语句。

通过这些导入语句, 浏览器 或 Node 就能确定加载代码的方式。

通过指定一个入口文件,然后从这个文件开始,通过其中的import语句,查找其他代码。

image.png

通过指定的文件路径, 浏览器就找到了目标代码文件。 但是浏览器并不能直接使用这些文件,它需要解析所有这些文件,以将它们转换为称为模块记录的数据结构。

image.png

然后,需要将 模块记录 转换为 模块实例

image.png

模块实例, 实际上是 「 代码 」(指令列表)与「 状态」(所有变量的值)的组合。

对于整个系统而言, 我们需要的是每个模块的模块实例。

模块加载的过程将从入口文件变为具有完整的模块实例图。

对于ES模块,这分为 三个步骤

  1. 构造—查找,下载所有文件并将其解析为模块记录。
  2. 实例化—查找内存中的框以放置所有导出的值(但尚未用值填充它们)。然后使导出和导入都指向内存中的那些框,这称为链接。
  3. 运行—运行代码以将变量的实际值填充到框中。

image.png

在构建阶段时, 发生三件事情:

  1. 找出从何处下载包含模块的文件
  2. 提取文件(通过从URL下载文件或从文件系统加载文件)
  3. 将文件解析为模块记录

1. 查找

首先,需要找到入口点文件。

在HTML中,可以通过脚本标记告诉加载程序在哪里找到它。

image.png

但是,如何找到下一组模块, 也就是 main.js 直接依赖的模块呢?

这就是导入语句的来源。

导入语句的一部分称为模块说明符, 它告诉加载程序可以在哪里找到每个下一个模块。

image.png

在解析文件之前,我们不知道模块需要获取哪些依赖项,并且在提取文件之前,也无法解析文件。

这意味着我们必须逐层遍历树,解析一个文件,然后找出其依赖项,然后查找并加载这些依赖项。

image.png

如果主线程要等待这些文件中的每个文件下载,则许多其他任务将堆积在其队列中。

那是因为当浏览器中工作时,下载部分会花费很长时间。

image.png

这样阻塞主线程会使使用模块的应用程序使用起来太慢。

这是ES模块规范将算法分为多个阶段的原因之一。

将构造分为自己的阶段,使浏览器可以在开始实例化的同步工作之前获取文件并建立对模块图的理解。

这种方法(算法分为多个阶段)是 ESMCommonJS模块 之间的主要区别之一。

CommonJS可以做不同的事情,因为从文件系统加载文件比通过Internet下载花费的时间少得多。

这意味着Node可以在加载文件时阻止主线程。

并且由于文件已经加载,因此仅实例化和求值(在CommonJS中不是单独的阶段)是有意义的。

这也意味着在返回模块实例之前,需要遍历整棵树,加载,实例化和评估任何依赖项。

该图显示了一个Node模块评估一个require语句,然后Node将同步加载和评估该模块及其任何依赖项

在具有CommonJS模块的Node中,可以在模块说明符中使用变量。

require在寻找下一个模块之前,正在执行该模块中的所有代码。这意味着当进行模块解析时,变量将具有一个值。

但是,使用ES模块时,需要在进行任何评估之前预先建立整个模块图。

这意味着不能在模块说明符中包含变量,因为这些变量还没有值。

使用变量的require语句很好。 使用变量的导入语句不是。

但是,有时将变量用于模块路径确实很有用。

例如,你可能要根据代码在做什么,或者在不同环境中运行来记载不同的模块。

为了使ES模块成为可能,有一个建议叫做动态导入。有了它,您可以使用类似的导入语句:

import(`${path}/foo.js`)

这种工作方式是将使用加载的任何文件import()作为单独图的入口点进行处理。

动态导入的模块将启动一个新图,该图将被单独处理。

两个模块图之间具有依赖性,并用动态导入语句标记

但是要注意一件事–这两个图中的任何模块都将共享一个模块实例。

这是因为加载程序会缓存模块实例。对于特定全局范围内的每个模块,将只有一个模块实例。

这意味着发动机的工作量更少。

例如,这意味着即使多个模块依赖该模块文件,它也只会被提取一次。(这是缓存模块的一个原因。我们将在评估部分中看到另一个原因。)

加载程序使用称为模块映射的内容来管理此缓存。每个全局变量在单独的模块图中跟踪其模块。

当加载程序获取一个URL时,它将把该URL放入模块映射中,并记下它当前正在获取文件。然后它将发出请求并继续以开始获取下一个文件。

加载程序图填充在“模块映射表”中,主模块的URL在左侧,而“获取”一词在右侧

如果另一个模块依赖于同一文件会怎样?加载程序将在模块映射中查找每个URL。如果在其中看到fetching,它将继续前进到下一个URL。

但是模块图不仅跟踪正在获取的文件。模块映射还充当模块的缓存,如下所示。

2. 解析

现在我们已经获取了该文件,我们需要将其解析为模块记录。这有助于浏览器了解模块的不同部分。

该图显示了被解析成模块记录的main.js文件

创建模块记录后,它将被放置在模块图中。这意味着无论何时从此处请求,加载程序都可以将其从该映射中拉出。

模块映射图中的“获取”占位符被模块记录填充

解析中有一个细节看似微不足道,但实际上有很大的含义。

解析所有模块,就像它们"use strict"位于顶部一样。还存在其他细微差异。

例如,关键字await是在模块的顶级代码保留,的值this就是undefined

这种不同的解析方式称为“解析目标”。如果解析相同的文件但使用不同的目标,那么最终将得到不同的结果。
因此,需要在开始解析之前就知道要解析的文件类型是否是模块。

在浏览器中,这非常简单。只需放入type="module"的script标签。
这告诉浏览器应将此文件解析为模块。并且由于只能导入模块,因此浏览器知道任何导入也是模块。

加载程序确定main.js是一个模块,因为script标签上的type属性表明是这样,而counter.js必须是一个模块,因为它已导入

但是在Node中,您不使用HTML标记,因此无法选择使用type属性。社区尝试解决此问题的一种方法是使用 .mjs扩展。使用该扩展名告诉Node,“此文件是一个模块”。您会看到人们在谈论这是解析目标的信号。目前讨论仍在进行中,因此尚不清楚Node社区最终决定使用什么信号。

无论哪种方式,加载程序都将确定是否将文件解析为模块。如果它是一个模块并且有导入,则它将重新开始该过程,直到提取并解析了所有文件。

我们完成了!在加载过程结束时,您已经从只有入口点文件变为拥有大量模块记录。

建设阶段的结果,左侧为JS文件,右侧为3个已解析的模块记录

下一步是实例化此模块并将所有实例链接在一起。

3. 实例化

就像我之前提到的,实例将代码与状态结合在一起。

该状态存在于内存中,因此实例化步骤就是将所有事物连接到内存。

首先,JS引擎创建一个模块环境记录。这将管理模块记录的变量。然后,它将在内存中找到所有导出的框。模块环境记录将跟踪与每个导出关联的内存中的哪个框。

内存中的这些框尚无法获取其值。只有在评估之后,它们的实际值才会被填写。该规则有一个警告:在此阶段中初始化所有导出的函数声明。这使评估工作变得更加容易。

为了实例化模块图,引擎将进行深度优先的后顺序遍历。这意味着它将下降到图表的底部-底部的不依赖其他任何内容的依赖项-并设置其导出。

中间的一列空内存。 计数和显示模块的模块环境记录已连接到内存中的框。

引擎完成了模块下面所有出口的接线-模块所依赖的所有出口。然后,它返回一个级别,以连接来自该模块的导入。

请注意,导出和导入均指向内存中的同一位置。首先连接出口,可以确保所有进口都可以连接到匹配的出口。

与上图相同,但具有main.js的模块环境记录,现在其导入链接到其他两个模块的导出。

这不同于CommonJS模块。在CommonJS中,整个导出对象在导出时被复制。这意味着导出的任何值(例如数字)都是副本。

这意味着,如果导出模块以后更改了该值,则导入模块将看不到该更改。

中间的内存中,有一个导出的通用JS模块指向一个内存位置,然后将值复制到另一个内存位置,而导入的JS模块则指向新位置

相反,ES模块使用称为实时绑定的东西。两个模块都指向内存中的相同位置。这意味着,当导出模块更改值时,该更改将显示在导入模块中。

导出值的模块可以随时更改这些值,但是导入模块不能更改其导入的值。话虽如此,如果模块导入了一个对象,则它可以更改该对象上的属性值。

导出模块更改内存中的值。 导入模块也尝试但失败。

之所以拥有这样的实时绑定,是因为您可以在不运行任何代码的情况下连接所有模块。当您具有循环依赖性时,这将有助于评估,如下所述。

因此,在此步骤结束时,我们已连接了所有实例以及导出/导入变量的存储位置。

现在我们可以开始评估代码,并用它们的值填充这些内存位置。

4. 执行

最后一步是将这些框填充到内存中。JS引擎通过执行顶级代码(函数外部的代码)来实现此目的。

除了仅在内存中填充这些框外,评估代码还可能触发副作用。例如,模块可能会调用服务器。

模块将在功能之外进行编码,标记为顶级代码

由于存在潜在的副作用,您只需要评估模块一次。与实例化中发生的链接可以完全相同的结果执行多次相反,评估可以根据您执行多少次而得出不同的结果。

这是拥有模块映射的原因之一。模块映射通过规范的URL缓存模块,因此每个模块只有一个模块记录。这样可以确保每个模块仅执行一次。与实例化一样,这是深度优先的后遍历。

那我们之前谈到的那些周期呢?

在循环依赖关系中,您最终在图中有一个循环。通常,这是一个漫长的循环。但是为了解释这个问题,我将使用一个简短的循环的人为例子。

左侧为4个模块循环的复杂模块图。 右侧有一个简单的2个模块循环。

让我们看一下如何将其与CommonJS模块一起使用。首先,主模块将执行直到require语句。然后它将去加载计数器模块。

一个commonJS模块,其变量是在require语句之后从main.js导出到counter.js的,具体取决于该导入

然后,计数器模块将尝试message从导出对象进行访问。但是由于尚未在主模块中对此进行评估,因此它将返回undefined。JS引擎将在内存中为局部变量分配空间,并将其值设置为undefined。

中间的内存,main.js和内存之间没有连接,但是从counter.js到未定义的内存位置的导入链接

评估一直持续到计数器模块顶级代码的末尾。我们想看看我们是否最终将获得正确的消息值(在评估main.js之后),因此我们设置了超时时间。然后评估在上恢复main.js

counter.js将控制权返回给main.js,从而完成评估

消息变量将被初始化并添加到内存中。但是由于两者之间没有连接,因此在所需模块中它将保持未定义状态。

main.js获取到内存的导出连接并填写正确的值,但是counter.js仍指向其中未定义的其他内存位置

如果使用实时绑定处理导出,则计数器模块最终将看到正确的值。到超时运行时,main.js的评估将完成并填写值。

支持这些循环是ES模块设计背后的重要理由。正是这种设计使它们成为可能。


(以上是关于 ESM 的理论介绍, 原文链接在文末)。

Bundle & Bundleless

谈及 Bundleless 的优势,首先是启动快

因为不需要过多的打包,只需要处理修改后的单个文件,所以响应速度是 O(1) 级别,刷新即可即时生效,速度很快。

image.png

所以, 在开发模式下,相比于Bundle,Bundleless 有着巨大的优势。

基于 Webpack 的 bundle 开发模式

image.png
上面的图具体的模块加载机制可以简化为下图:
image.png
在项目启动和有文件变化时重新进行打包,这使得项目的启动和二次构建都需要做较多的事情,相应的耗时也会增长。

基于 ESModule 的 Bundleless 模式

image.png
从上图可以看到,已经不再有一个构建好的 bundle、chunk 之类的文件,而是直接加载本地对应的文件。
image.png
从上图可以看到,在 Bundleless 的机制下,项目的启动只需要启动一个服务器承接浏览器的请求即可,同时在文件变更时,也只需要额外处理变更的文件即可,其他文件可直接在缓存中读取。

对比总结

image.png

Bundleless 模式可以充分利用浏览器自主加载的特性,跳过打包的过程,使得我们能在项目启动时获取到极快的启动速度,在本地更新时只需要重新编译单个文件。

实现一个乞丐版 Vite

Vite 也是基于 ESM 的, 文件处理速度 O(1)级别, 非常快。

作为探索, 我就简单实现了一个乞丐版Vite:

GitHub 地址: Vite-mini

image.png

简要分析一下。

<body>
  <div id="app"></div>
  <script type="module" data-original="/src/main.js"></script>
</body>

html 文件中直接使用了浏览器原生的 ESM(type="module") 能力。

所有的 js 文件经过 vite 处理后,其 import 的模块路径都会被修改,在前面加上 /@modules/。当浏览器请求 import 模块的时候,vite 会在 node_modules 中找到对应的文件进行返回。

image.png

其中最关键的步骤就是模块的记载和解析, 这里我简单用koa简单实现了一下, 整体结构:

const fs = require('fs');
const path = require('path');
const Koa = require('koa');
const compilerSfc = require('@vue/compiler-sfc');
const compileDom = require('@vue/compiler-dom');
const app = new Koa();

// 处理引入路径
function rewriteImport(content) {
  // ...
}

// 处理文件类型等, 比如支持ts, less 等类似webpack的loader的功能
app.use(async (ctx) => {
  // ...
}

app.listen(3001, () => {
  console.log('3001');
});

我们先看路径相关的处理:

function rewriteImport(content) {
    return content.replace(/from ['"]([^'"]+)['"]/g, function (s0, s1) {
        // import a from './c.js' 这种格式的不需要改写
        // 只改写需要去node_module找的
        if (s1[0] !== '.' && s1[0] !== '/') {
          return `from '/@modules/${s1}'`;
        }
        return s0;
    });
}

处理文件内容: 源码地址

image.png

后续的都是类似的:

image.png

这个代码只是解释实现原理, 不同的文件类型处理逻辑其实可以抽离出去, 以中间件的形式去处理。

代码实现的比较简单, 就不额解释了。

Snowpack

image.png

和 webpack 的对比:

image.png

我使用 Snowpack 做了个 demo , 支持打包, 输出 bundle。

github: Snowpack-React-Demo

image.png

能够清晰的看到, 控制台产生了大量的文件请求(也叫瀑布网络请求),

不过因为都是加载的本地文件, 所以速度很快。

配合HMR, 实现编辑完成立刻生效, 几乎不用等待:

image.png

但是如果是在生产中,这些请求对于生产中的页面加载时间而言, 就不太好了。

尤其是HTTP1.1,浏览器都会有并行下载的上限,大部分是5个左右,所以如果你有60个依赖性要下载,就需要等好长一点。

虽然说HTTP2多少可以改善这问题,但若是东西太多,依然没办法。

关于这个项目的打包, 直接执行build:

image.png

打包完成后的文件目录,和传统的 webpack 基本一致:

image.png

在 build 目录下启动一个静态文件服务:

image.png

build 模式下,还是借助了 webpack 的打包能力:

image.png

做了资源合并:

image.png

就这点而言, 我认为未来一段时间内, 生产环境还是不可避免的要走bundle模式。

bundleless 模式在实际开发中的一些问题

开门见山吧, 开发体验不是很友好,几点比较突出的问题:

  • 部分模块没有提供 ESModule 的包。(这一点尤为致命)
  • 生态不够健全,工具链不够完善;

当然还有其他方方面面的问题, 就不一一列举。

我简单改造了一个页面, 就遇到很多奇奇怪怪的问题, 开发起来十分难受, 尽管代码的修改能立刻生效。

结论

bundleless 能在开发模式下带了很大的便利。 但就目前来说,要运用到生产的话, 还是有一段路要走的。

就目当下而言, 如果真的要用的话,可能还是 bundleless(dev) + bundle(production) 的组合。

至于未来能不能全面铺开 bundleless,我认为还是有可能的, 交给时间吧。

结尾

本文主要介绍了 esm 的原理, 以及介绍了以此为基础的Vite, Snowpack 等工具, 提供了两个可运行的 demo:

  1. Vite-mini
  2. Snowpack-React-Demo

并探索了 bundleless 在生产中的可行性。

Bundleless, 本质上是将原先 Webpack 中模块依赖解析的工作交给浏览器去执行,使得在开发过程中代码的转换变少,极大地提升了开发过程中的构建速度,同时也可以更好地利用浏览器的相关开发工具。

最后,也非常感谢 ESModule、Vite、Snowpack 等标准和工具的出现,为前端开发提效。

才疏学浅, 文中若有错误,还能各位大佬指正, 谢谢。

参考资料

  1. https://hacks.mozilla.org/201...
  2. https://developer.aliyun.com/...

如果你觉得这篇内容对你挺有启发,可以:

  1. 点个「在看」,让更多的人也能看到这篇内容。
  2. 关注公众号「前端e进阶」,掌握前端面试重难点,公众号后台回复「加群」和小伙伴们畅聊技术。

图片

查看原文

赞 47 收藏 21 评论 5

jayzou 回答了问题 · 2020-08-25

Vue可否在render函数操作VNode,怎么操作?

关注 4 回答 6

jayzou 赞了文章 · 2020-05-13

基于 Flutter+Dart 聊天实例 | Flutter 仿微信界面聊天室

1、项目介绍

Flutter是目前比较流行的跨平台开发技术,凭借其出色的性能获得很多前端技术爱好者的关注,比如阿里闲鱼美团腾讯等大公司都有投入相关案例生产使用。
flutter_chatroom项目是基于Flutter+Dart+chewie+photo_view+image_picker等技术开发的跨平台仿微信app聊天界面应用,实现了消息/表情发送、图片预览、长按菜单、红包/小视频/朋友圈等功能。
022360截图20200512003659242.png

2、技术框架

  • 使用技术:Flutter 1.12.13/Dart 2.7.0
  • 视频组件:chewie: ^0.9.7
  • 图片/拍照:image_picker: ^0.6.6+1
  • 图片预览组件:photo_view: ^0.9.2
  • 弹窗组件:showModalBottomSheet/AlertDialog/SnackBar
  • 本地存储:shared_preferences: ^0.5.7+1
  • 字体图标:阿里iconfont字体图标库

001360截图20200512002407906.png

003360截图20200512002631530.png

004360截图20200512002755155.png

005360截图20200512002840849.png

007360截图20200512002934978.png

008360截图20200512003004490.png

009360截图20200512003023266.png

011360截图20200512003108139.png

014360截图20200512003208370.png

016360截图20200512003322336.png

018360截图20200512003422368.png

019360截图20200512003435098.png

021360截图20200512003604679.png

023360截图20200512003901929.png

026360截图20200512004446202.png

025360截图20200512004305675.png

029360截图20200512004708377.png

031360截图20200512005508992.png

鉴于flutter基于dart语言,需要安装Dart Sdk / Flutter Sdk,至于如何搭建开发环境,可以去官网查阅文档资料

https://flutter.cn/

https://flutterchina.club/

https://pub.flutter-io.cn/

https://www.dartcn.com/

使用vscode编辑器,可先安装DartFlutterFlutter widget snippets等扩展插件

3、flutter沉浸式状态栏/底部tabbar

flutter中如何实现顶部全背景沉浸式透明状态栏(去掉状态栏黑色半透明背景),去掉右上角banner,可以去看这篇文章
https://segmentfault.com/a/11...

4、flutter图标组件/IconData自定义封装组件

  • 1、使用系统图标组件: Icon(Icons.search) 
  • 2、使用IconData方式: Icon(IconData(0xe60e, fontFamily:'iconfont'), size:24.0)

使用第二种方式需要先下载阿里图标库字体文件,然后在pubspec.yaml中引入字体
360截图20200513090806912.png

class GStyle {
    // __ 自定义图标
    static iconfont(int codePoint, {double size = 16.0, Color color}) {
        return Icon(
            IconData(codePoint, fontFamily: 'iconfont', matchTextDirection: true),
            size: size,
            color: color,
        );
    }
}

调用非常简单,可自定义颜色、字体大小;
GStyle.iconfont(0xe635, color: Colors.orange, size: 17.0)

5、flutter实现badge红点/圆点提示

360截图20200513091117720.png
如上图:在flutter中没有圆点提示组件,需要自己封装实现;

class GStyle {
    // 消息红点
    static badge(int count, {Color color = Colors.red, bool isdot = false, double height = 18.0, double width = 18.0}) {
        final _num = count > 99 ? '···' : count;
        return Container(
            alignment: Alignment.center, height: !isdot ? height : height/2, width: !isdot ? width : width/2,
            decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(100.0)),
            child: !isdot ? Text('$_num', style: TextStyle(color: Colors.white, fontSize: 12.0)) : null
        );
    }
}

支持自定义红点大小、颜色,默认数字超过99就...显示;
GStyle.badge(0, isdot:true)
GStyle.badge(13)
GStyle.badge(29, color: Colors.orange, height: 15.0, width: 15.0)

6、flutter长按自定义弹窗

  • 在flutter中如何实现长按,并在长按位置弹出菜单,类似微信消息长按弹窗效果;

360截图20200513091947231.png
通过InkWell组件提供的onTapDown事件获取坐标点实现

InkWell(
    splashColor: Colors.grey[200],
    child: Container(...),
    onTapDown: (TapDownDetails details) {
        _globalPositionX = details.globalPosition.dx;
        _globalPositionY = details.globalPosition.dy;
    },
    onLongPress: () {
        _showPopupMenu(context);
    },
),
// 长按弹窗
double _globalPositionX = 0.0; //长按位置的横坐标
double _globalPositionY = 0.0; //长按位置的纵坐标
void _showPopupMenu(BuildContext context) {
    // 确定点击位置在左侧还是右侧
    bool isLeft = _globalPositionX > MediaQuery.of(context).size.width/2 ? false : true;
    // 确定点击位置在上半屏幕还是下半屏幕
    bool isTop = _globalPositionY > MediaQuery.of(context).size.height/2 ? false : true;

    showDialog(
      context: context,
      builder: (context) {
        return Stack(
          children: <Widget>[
            Positioned(
              top: isTop ? _globalPositionY : _globalPositionY - 200.0,
              left: isLeft ? _globalPositionX : _globalPositionX - 120.0,
              width: 120.0,
              child: Material(
                ...
              ),
            )
          ],
        );
      }
    );
}
  • flutter如何实现去掉AlertDialog弹窗大小限制?

可通过SizedBox和无限制容器UnconstrainedBox组件实现

void _showCardPopup(BuildContext context) {
    showDialog(
      context: context,
      builder: (context) {
        return UnconstrainedBox(
          constrainedAxis: Axis.vertical,
          child: SizedBox(
            width: 260,
            child: AlertDialog(
              content: Container(
                ...
              ),
              elevation: 0,
              contentPadding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 20.0),
            ),
          ),
        );
      }
    );
}

7、flutter登录/注册表单|本地存储

flutter提供了两个文本框组件:TextFieldTextFormField
本文是使用TextField实现,并在文本框后添加清空文本框/密码查看图标

TextField(
  keyboardType: TextInputType.phone,
  controller: TextEditingController.fromValue(TextEditingValue(
    text: formObj['tel'],
    selection: new TextSelection.fromPosition(TextPosition(affinity: TextAffinity.downstream, offset: formObj['tel'].length))
  )),
  decoration: InputDecoration(
    hintText: "请输入手机号",
    isDense: true,
    hintStyle: TextStyle(fontSize: 14.0),
    suffixIcon: Visibility(
      visible: formObj['tel'].isNotEmpty,
      child: InkWell(
        child: GStyle.iconfont(0xe69f, color: Colors.grey, size: 14.0), onTap: () {
          setState(() { formObj['tel'] = ''; });
        }
      ),
    ),
    border: OutlineInputBorder(borderSide: BorderSide.none)
  ),
  onChanged: (val) {
    setState(() { formObj['tel'] = val; });
  },
)

TextField(
  decoration: InputDecoration(
    hintText: "请输入密码",
    isDense: true,
    hintStyle: TextStyle(fontSize: 14.0),
    suffixIcon: InkWell(
      child: Icon(formObj['isObscureText'] ? Icons.visibility_off : Icons.visibility, color: Colors.grey, size: 14.0),
      onTap: () {
        setState(() {
          formObj['isObscureText'] = !formObj['isObscureText'];
        });
      },
    ),
    border: OutlineInputBorder(borderSide: BorderSide.none)
  ),
  obscureText: formObj['isObscureText'],
  onChanged: (val) {
    setState(() { formObj['pwd'] = val; });
  },
)

验证消息提示则是使用flutter提供的SnackBar实现

// SnackBar提示
final _scaffoldkey = new GlobalKey<ScaffoldState>();
void _snackbar(String title, {Color color}) {
    _scaffoldkey.currentState.showSnackBar(SnackBar(
      backgroundColor: color ?? Colors.redAccent,
      content: Text(title),
      duration: Duration(seconds: 1),
    ));
}

另外本地存储使用的是shared\_preferences,至于如何使用可参看
https://pub.flutter-io.cn/packages/shared_preferences

void handleSubmit() async {
    if(formObj['tel'] == '') {
      _snackbar('手机号不能为空');
    }else if(!Util.checkTel(formObj['tel'])) {
      _snackbar('手机号格式有误');
    }else if(formObj['pwd'] == '') {
      _snackbar('密码不能为空');
    }else {
      // ...接口数据

      // 设置存储信息
      final prefs = await SharedPreferences.getInstance();
      prefs.setBool('hasLogin', true);
      prefs.setInt('user', int.parse(formObj['tel']));
      prefs.setString('token', Util.setToken());

      _snackbar('恭喜你,登录成功', color: Colors.greenAccent[400]);
      Timer(Duration(seconds: 2), (){
        Navigator.pushNamedAndRemoveUntil(context, '/tabbarpage', (route) => route == null);
      });
    }
}

8、flutter聊天页面功能

360截图20200513093616798.png

  • 在flutter中如何实现类似上图编辑器功能?通过TextField提供的多行文本框属性maxLines就可实现。
Container(
    margin: GStyle.margin(10.0),
    decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(3.0)),
    constraints: BoxConstraints(minHeight: 30.0, maxHeight: 150.0),
    child: TextField(
        maxLines: null,
        keyboardType: TextInputType.multiline,
        decoration: InputDecoration(
          hintStyle: TextStyle(fontSize: 14.0),
          isDense: true,
          contentPadding: EdgeInsets.all(5.0),
          border: OutlineInputBorder(borderSide: BorderSide.none)
        ),
        controller: _textEditingController,
        focusNode: _focusNode,
        onChanged: (val) {
          setState(() {
            editorLastCursor = _textEditingController.selection.baseOffset;
          });
        },
        onTap: () {handleEditorTaped();},
    ),
),
  • flutter实现滚动聊天信息到最底部

通过ListView里controller属性提供的jumpTo方法及_msgController.position.maxScrollExtent

ScrollController _msgController = new ScrollController();
...
ListView(
    controller: _msgController,
    padding: EdgeInsets.all(10.0),
    children: renderMsgTpl(),
)

// 滚动消息至聊天底部
void scrollMsgBottom() {
    timer = Timer(Duration(milliseconds: 100), () => _msgController.jumpTo(_msgController.position.maxScrollExtent));
}

行了,基于flutter/dart开发聊天室实例就介绍到这里,希望能喜欢~~💪💪

最后附上electron桌面端应用实例
electron聊天室|vue+electron-vue仿微信客户端|electron桌面聊天
360截图20190807103937042.jpg

查看原文

赞 46 收藏 27 评论 4

jayzou 发布了文章 · 2020-05-12

打造一个媲美Ctrf+F的搜索组件

背景

Ctrf+F相信大家一定不陌生,很多人都依赖Ctrf+F来搜索网页上想要看到的内容,也是最频繁使用的功能之一。浏览器之间存在兼容性问题,但是肯定都提供原生搜索框(而且快捷键都是ctrf+f)

原生搜索功能

这里简单介绍下原生搜索框提供了哪些功能
image.png

  1. 关键词高亮
  2. 当前页面命中关键词数量和当前选中的关键词序号
  3. 当前选中关键词高亮
  4. 切换到上/下一个关键词
  5. esc关闭搜索

功能简单且强大,只要是页面内渲染出来的文本都能搜索且定位到目标

不足

ctrf+f的功能满足我们日常所需,那么是不是存在不足之处呢?来看看几个案例

  1. 2个以上div标签组成的连续文本
    image.png

    <div style="display:flex">  
        <div>搜索</div>  
        <div>测试</div>  
    </div>
    
  2. 手风琴组件被折叠的文本也会搜索出来
    image.png

    <div>搜索</div>  
    <div style="height: 0px;">测试</div>  
  3. 页面级别的搜索,某些非正文内容也在搜索范围,比如导航、底部、广告

想法

针对不足点1,其实document.querySelector('.box').textContent打印出来结果是搜索测试中间有个换行符,所以导致浏览器无法搜索出来。在富文本的场景下,这种结构的html很常见,比如某段文字中插入几个高亮文字来做tooltip解释说明。理想的结果当然是能自动生成连续的文字再搜索

针对不足点2、3,也是因为我们不希望站内某些内容被索引到,或者说索引到的时候能自动展开,比如手风琴

方案

需要实现一个类似ctrf+f的搜索功能,并且跟框架无关,那么只有从dom上面入手

  1. 需要一个标识来识别连续文本,通过深度遍历的方式拼接文本同时记录文本节点所在的位置,主要是为了不破坏原有dom结构的同时高亮跨标签的文本,比如

    <div style="display:flex">  
        <div>搜索</div>  
        <div>测试</div>  
    </div>
    <!-- 替换成 -->
    <div style="display:flex">  
        <div><mark>搜索</mark></div>      <!-- mark就是高亮标签 -->
        <div><mark>测试</mark></div>  
    </div>
  2. 深度遍历dom结构的同时需要做几个逻辑

    1. 记录滚动节点,可以根据scrollHeight、clientHeight、scrollWidth、clientWidth判断元素是否存在滚动条
    2. 过滤无关标签,比如设置了display:none或者height:0,当然也可以自己定义不搜索特定的class
  3. 圈定搜索范围,满足不足点3
  4. 修复不合理的文本标签,具体原因后面解释

    <div id="error">
        这是异常的
        <span>节点</span>
    </div>
    <!-- 替换为 -->
    <div id="error">
        <span>这是异常的</span>
        <span>节点</span>
    </div>
  5. 替换高亮标签的同时保留原有的文本,便于后续恢复

    <div>  
        搜索测试
    </div>
    <!-- 关键词 搜索,dom结构替换为 -->
    <div>  
      <mark>搜索</mark>  
      测试
      <template>搜索测试</template>
    </div>
  6. 命中关键词总数量和上/下功能通过数组和当前下标值来确定
  7. 提供上下定位到某个高亮关键词功能~~~~

逻辑处理

圈定搜索范围

传入一个class或者id即可,做为后续深度遍历的顶层节点
const dom = document.querySelector(classname)

深度递归遍历dom结构

  1. 记录滚动节点
  2. 过滤不显示(height:0display:none)和黑名单标签
  3. 记录长文本(拼接搜索区域出现过关键词字串的文本)和节点文本在长文本中起始和结束位置
formatDom(el, value) {
        const childList = el.childNodes
        if (!childList.length || !value.length) return // 无子节点或无查询值,则不进行下列操作
        childList.forEach(el => {
            // 遍历其内子节点
            if (el.nodeType === 1 || el.nodeType === 3) {
                //页面内存在滚动节点的话,需要记录
                if (isRealNode(el)) {
                    if(el.scrollHeight > el.clientHeight) {
                        //纵向滚动条
                        this.overflowYDom.push(el)
                    }
                    if(el.scrollWidth > el.clientWidth) {
                        //横向滚动条
                        this.overflowXDom.push(el)
                    }
                }
                if (
                    isRealNode(el) && // 如果是元素节点
                    checkClassName(el, this.blackClassName) &&
                    !/(script|style|template)/i.test(el.tagName)
                ) {
                    // 并且元素标签不是script或style或template等特殊元素
                    this.formatDom(el, value) // 那么就继续遍历(递归)该元素节点
                } else if (el.nodeType === 3) {
                    // 记录关键词中字串出现过的文本节点
                    for (let j = 0; j < value.length; j++) {
                        if (el.data.indexOf(value[j]) > -1) {
                            const start = this.searchDom.text.length
                            this.searchDom.text = this.searchDom.text + el.parentNode.innerText     //拼接文本,便于后续处理跨文本标签匹配
                            this.searchDom.data[`${start}-${this.searchDom.text.length - 1}`] = el                  //记录每个文本节点内容在全文本中起始下标位置
                            break
                        }
                    }
                }
            }
        })
    }

处理跨标签文本

假设原始dom长这样

<div>
    <h2>跨标签文案</h2>
    <div class="flex">
        <div>
            这是一段跨标签
        </div>
        <div>
            跨多个标签
        </div>
        <div>
            组成的文案
        </div>
        <div>
            测试
        </div>
    </div>
</div>

搜索关键词:文案测试

上一步已经获取到长文本(搜索区域拼接的全部文本内容)和节点出现在长文本的下标
长文本跨标签文案组成的文案测试
节点出现在长文本的下标:

{
    '0-4': textElement,
    '5-9': textElement,
    '10-11': textElement
}

注:这里只收集包含关键词字串的节点和文本内容,比如 这是一段跨标签 这个节点就不收集

  1. 先找到搜索关键词在长文本中出现的起始位置

        searchSubStr(str, subStr) {
            //str 长文本
            //subStr 关键词
            let arr = []
            let index = str.indexOf(subStr)
            while (index > -1) {
                arr.push(index)
                index = str.indexOf(subStr, index + 1)
            }
            return arr
        }
        //返回 [8]
  2. 根据起始位置加上关键词长度得到匹配的区域
    比如关键词所在的位置就是 8 - 12(关键词长度 4),从收集的节点出现在长文本的下标,发现跨2个text节点,那么就需要同时高亮2个节点
  3. 计算高亮节点(可能跨标签),具体逻辑这里就不多描述了,感兴趣可以参考源码

高亮节点

代码细节可以参考源码,有几个需要注意的事项

  1. 需要修复异常节点,比如

     <div id="error">
         这是异常的
         <span>节点</span>
     </div>
     <!-- 替换为 -->
     <div id="error">
         <span>这是异常的</span>
         <span>节点</span>
     </div>

    原因在于这是异常的这个text节点存在一个兄弟节点 span,取消高亮之后无法复原(可能有别的办法,但是考虑成本过高,就不考虑),复原方案参考下面

  2. 替换高亮节点的同时保留template方便后续复原,跨标签节点标记同个id(mark-id),方便上下选中的计算
    image.png

总结

demo传送门
篇幅有限,其它细节就不一一解释,大家可以看下源码,顺便求个start~~~

查看原文

赞 5 收藏 3 评论 0

jayzou 赞了文章 · 2020-05-11

深度:从零编写一个微前端框架

写在开头:

手写框架体系文章,缺手写vue和微前端框架文章,今日补上微前端框架,觉得写得不错,记得点个关注+在看,转发更好


对源码有兴趣的,可以看我之前的系列手写源码文章

微前端框架是怎么导入加载子应用的  【3000字精读】

原创:带你从零看清Node源码createServer和负载均衡整个过程

原创:从零实现一个简单版React (附源码)

精读:10个案例让你彻底理解React hooks的渲染逻辑

原创:如何自己实现一个简单的webpack构建工具 【附源码】

从零解析webRTC.io Server端源码


正式开始:

对于微前端,最近好像很火,之前我公众号也发过比较多微前端框架文章

深度:微前端在企业级应用中的实践  (1万字,华为)

万字解析微前端、微前端框架qiankun以及源码

那么现在我们需要手写一个微前端框架,首先得让大家知道什么是微前端,现在微前端模式分很多种,但是大都是一个基座+多个子应用模式,根据子应用注册的规则,去展示子应用。

这是目前的微前端框架基座加载模式的原理,基于single-spa封装了一层,我看有不少公司是用Vue做加载器(有天然的keep-alive),还有用angular和web components技术融合的


首先项目基座搭建,这里使用parcel

mkdir pangu 
yarn init 
//输入一系列信息
yarn add parcel@next

然后新建一个index.html文件,作为基座


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    
</body>
</html>

新建一个index.js文件,作为基座加载配置文件

新建src文件夹,作为pangu框架的源码文件夹,

新建example案例文件夹

现在项目结构长这样


既然是手写,就不依赖其他任何第三方库

我们首先需要重写hashchange popstate这两个事件,因为微前端的基座,需要监听这两个事件根据注册规则去加载不同的子应用,而且它的实现必须在React、vue子应用路由组件切换之前,单页面的路由源码原理实现,其实也是靠这两个事件实现,之前我写过一篇单页面实现原理的文章,不熟悉的可以去看看

https://segmentfault.com/a/1190000019936510

const HIJACK_EVENTS_NAME = /^(hashchange|popstate)$/i;
const EVENTS_POOL = {
  hashchange: [],
  popstate: [],
};

window.addEventListener('hashchange', loadApps);
window.addEventListener('popstate', loadApps);

const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function (eventName, handler) {
  if (
    eventName &&
    HIJACK_EVENTS_NAME.test(eventName) &&
    typeof handler === 'function'
  ) {
    EVENTS_POOL[eventName].indexOf(handler) === -1 &&
      EVENTS_POOL[eventName].push(handler);
  }
  return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, handler) {
  if (eventName && HIJACK_EVENTS_NAME.test(eventName)) {
    let eventsList = EVENTS_POOL[eventName];
    eventsList.indexOf(handler) > -1 &&
      (EVENTS_POOL[eventName] = eventsList.filter((fn) => fn !== handler));
  }
  return originalRemoveEventListener.apply(this, arguments);
};

function mockPopStateEvent(state) {
  return new PopStateEvent('popstate', { state });
}

// 拦截history的方法,因为pushState和replaceState方法并不会触发onpopstate事件,所以我们即便在onpopstate时执行了reroute方法,也要在这里执行下reroute方法。
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
window.history.pushState = function (state, title, url) {
  let result = originalPushState.apply(this, arguments);
  reroute(mockPopStateEvent(state));
  return result;
};
window.history.replaceState = function (state, title, url) {
  let result = originalReplaceState.apply(this, arguments);
  reroute(mockPopStateEvent(state));
  return result;
};

// 再执行完load、mount、unmout操作后,执行此函数,就可以保证微前端的逻辑总是第一个执行。然后App中的Vue或React相关Router就可以收到Location的事件了。
export function callCapturedEvents(eventArgs) {
  if (!eventArgs) {
    return;
  }
  if (!Array.isArray(eventArgs)) {
    eventArgs = [eventArgs];
  }
  let name = eventArgs[0].type;
  if (!HIJACK_EVENTS_NAME.test(name)) {
    return;
  }
  EVENTS_POOL[name].forEach((handler) => handler.apply(window, eventArgs));
}

上面代码很简单,创建两个队列,使用数组实现


const EVENTS_POOL = {
  hashchange: [],
  popstate: [],
};

如果检测到是hashchange popstate两种事件,而且它们对应的回调函数不存在队列中时候,那么就放入队列中。(相当于redux中间件原理)

然后每次监听到路由变化,调用reroute函数:

function reroute() {
  invoke([], arguments);
}

这样每次路由切换,最先知道变化的是基座,等基座同步执行完(阻塞)后,就可以由子应用的vue-Rourer或者react-router-dom等库去接管实现单页面逻辑了。


那,路由变化,怎么加载子应用呢?

像一些微前端框架会用import-html之类的这些库,我们还是手写吧

逻辑大概是这样,一共四个端口,nginx反向代理命中基座服务器监听的端口(用户必须首先访问到根据域名),然后去不同子应用下的服务器拉取静态资源然后加载。


提示:所有子应用加载后,只是在基座的一个div标签中加载,实现原理跟ReactDom.render()这个源码一样,可参考我之前的文章

原创:从零实现一个简单版React (附源码)


那么我们先编写一个registrApp方法,接受一个entry参数,然后去根据url变化加载子应用(传入的第二个参数activeRule


/**
 *
 * @param {string} entry
 * @param {string} function
 */
const Apps = [] //子应用队列
function registryApp(entry,activeRule) {
    Apps.push({
        entry,
        activeRule
    })
}

注册完了之后,就要找到需要加载的app

export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp.entry)
    .then(function (response) {
      return response.json();
    })
    .then(function (myJson) {
      console.log(myJson, 'myJson');
    });
}

shouldBeActive根据传入的规则去判断是否需要此时挂载:

export function shouldBeActive(app){
    return app.activeRule(window.location)
}

此时的res数据,就是我们通过get请求获取到的子应用相关数据,现在我们新增subapp1和subapp2文件夹,模拟部署的子应用,我们把它用静态资源服务器跑起来

subapp1.js作为subapp1的静态资源服务器

const express = require('express');

subapp2.js作为subapp2的静态资源服务器


const express = require('express');
const app = express();
const { resolve } = require('path');
app.use(express.static(resolve(__dirname, '../subapp1')));

app.listen(8889, (err) => {
  !err && console.log('8889端口成功');
});

现在文件目录长这样:

基座index.html运行在1234端口,subapp1部署在8889端口,subapp2部署在8890端口,这样我们从基座去拉取资源时候,就会跨域,所以静态资源服务器、webpack热更新服务器等服务器,都要加上cors头,允许跨域。


const express = require('express');
const app = express();
const { resolve } = require('path');
//设置跨域访问
app.all('*', function (req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'X-Requested-With');
  res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS');
  res.header('X-Powered-By', ' 3.2.1');
  res.header('Content-Type', 'application/json;charset=utf-8');
  next();
});
app.use(express.static(resolve(__dirname, '../subapp1')));

app.listen(8889, (err) => {
  !err && console.log('8889端口成功');
});

⚠️:如果是dev模式,记得在webpack的热更新服务器中配置允许跨域,如果你对webpack不是很熟悉,可以看我之前的文章:

万字硬核     从零实现webpack热更新HMR

原创:如何自己实现一个简单的webpack构建工具 【附源码】


这里我使用nodemon启用静态资源服务器,简单为主,如果你没有下载,可以:

npm i nodemon -g 
或
yarn add nodemon global 

这样我们先访问下8889,8890端口,看是否能访问到。

访问8889和8890都可以访问到对应的资源,成功


正式开启启用我们的微前端框架pangu.封装start方法,启用需要挂载的APP。


export function start(){
    loadApp()
}

注册子应用subapp1,subapp2,并且手动启用微前端


import { registryApp, start } from './src/index';
registryApp('localhost:8889', (location) => location.pathname === '/subapp1');
registryApp('localhost:8890', (location) => location.pathname === '/subapp2');
start()

修改index.html文件:


<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <div>
        <h1>基座</h1>
        <div class="subapp">
            <div>
                <a href="/subapp1">子应用1</a>
            </div>
            <div>
                <a href="/subapp2">子应用2</a>
            </div>
        </div>
        <div id="subApp"></div>
    </div>
</body>
<script data-original="./index.js"></script>

</html>

ok,运行代码,发现挂了,为什么会挂呢?因为那边返回的是html文件,我这里用的fetch请求,JSON解析不了

那么我们去看看别人的微前端和第三方库的源码吧,例如import-html-entry这个库

由于之前我解析过qiankun这个微前端框架源码,我这里就不做过度讲解,它们是对fetch做了一个text()。


export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp.entry)
    .then(function (response) {
      return response.text();
    })
    .then(function (myJson) {
      console.log(myJson, 'myJson');
    });
}

然后我们已经可以得到拉取回来的html文件了(此时是一个字符串)

由于现实的项目,一般这个html文件会包含js和css的引入标签,也就是我们目前的单页面项目,类似下面这样:

于是我们需要把脚本、样式、html文件分离出来。用一个对象存储

本想照搬某个微前端框架源码的,但是觉得它写得也就那样,今天又主要讲原理,还是自己写一个能跑的把,毕竟html的文件都回来了,数据处理也不难

export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp[0].entry)
    .then(function (response) {
      return response.text();
    })
    .then(function (text) {
      const dom = document.createElement('div');
      dom.innerHTML = text;
      console.log(dom, 'dom');
    });
}

先改造下,打印下DOM

发现已经能拿到dom节点了,那么我先处理下,让它展示在基座中


export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp[0].entry)
    .then(function (response) {
      return response.text();
    })
    .then(function (text) {
      const dom = document.createElement('div');
      dom.innerHTML = text;
      const content = dom.querySelector('h1');
      const subapp = document.querySelector('#subApp-content');
      subapp && subapp.appendChild(content);
    });
}

此时,我们已经可以加载不同的子应用了。

乞丐版的微前端框架就完成了,后面会逐步完善所有功能,向主流的微前端框架靠拢,并且完美支持IE11.记住它叫:pangu

推荐阅读之前的手写ws协议:

深度:手写一个WebSocket协议    [7000字]

最后

  • 欢迎加我微信(CALASFxiaotan),拉你进技术群,长期交流学习...
  • 欢迎关注「前端巅峰」,认真学前端,做个有专业的技术人...

点个赞支持我吧,转发就更好了

查看原文

赞 60 收藏 42 评论 3

jayzou 赞了文章 · 2020-03-12

程序员如何优雅的挣零花钱!9 种思路打开你挣钱的世界

如何优雅而不失体面!

虽然程序员有女朋友的不多(误),但是开销往往都不小。

VPS、域名、Mac 上那一堆的收费软件、还有 Apple 每年更新的那些设备,经常都是肾不够用的节奏。

幸好作为程序员,我们有更多挣钱的姿势。

有同学该嚷了:不就是做私单嘛。

对,但是也不太对。

做私单的确是一个简单直接方式,但在我看来,私单的投入产出比很差,并不是最优的。

但既然提到了,就先说说吧。

笔者博客首更地址:https://github.com/biaochenxuying/blog

1. 私单

1.1 按需雇用

按需雇用是近几年新兴的私单方式,开发者在业余时间直接到雇主公司驻场办公,按时薪领取报酬。这种方式省去了网络沟通的低效率,也避免了和雇主的讨价还价,适合怕麻烦的程序员。

程序员客栈

不太熟悉,但国内按需雇用的网站不多,写出来供大家参考。

1.2 远程外包

最理想的单子还是直接接海外的项目,比如 freelancer.com 等网站。

一方面是因为挣的是美刀比较划算,之前看到像给 WordPress 写支付+发送注册码这种大家一个周末就能做完的项目,也可以到 200~300 美刀;另一方面是在国外接单子比较隐蔽。

常用国外网站:

国内也有一个软件众包平台 CODING 码市

码市基于云计算技术搭建的云端软件开发平台 Coding.net 作为沟通和监管工具,快速连接开发者与需求方,旨在通过云端众包的方式提高软件交付的效率。

码市作为第三方监管平台,会对所有项目进行审核以保证项目需求的明确性,并提供付款担保,让开发者只要按时完成项目开发即可获取酬劳。

你可以 在这里 看到开发者对码市的评价。

当然,猪八戒这种站我就不多说了,不太适合专业程序员去自贬身价。

实现网

实现网 的价格也很不错,但是我强烈建议大家不要在介绍中透漏实名和真实的公司部门信息,因为这实在太高调了。

有同学说,这是我的周末时间啊,我爱怎么用就怎么用,公司还能告我怎么的?

虽然很多公司的劳动合同里边并不禁止做兼职,但在网上如此高调的干私活,简直就是在挑衅HR:「我工作不饱和」、「公司加班不够多」…

再想象下你一边和产品经理说这个需求做不完,一边自己却有时间做私单的样子。你自己要是老板也不愿提拔这样的人吧。

2. Side Project

比起做私单,做一个 Side Project 会更划算。

Side Project 的好处是你只需要对特定领域进行持续投入,就可以在很长时间获得收入。
这可以让你的知识都在一棵树上分支生长,从而形成良好的知识结构,而不是变成一瓶外包万金油。

思路有两种:

一种是做小而美的,针对一个细分领域开发一个功能型应用,然后放到市场上去卖;

另一种是做大而全的基础应用(想想 WordPress),方便别人在上边直接添加代码,定制成自己想要的应用。

前一种做起来比较快,但需要自己去做一些销售工作;后一种通常是开源/免费+收费模式,推广起来更简单。

有同学会说,我写的 Side Project 就是卖不掉啊。项目方向的选取的确是比较有技巧的,但简单粗暴的解决方案就是找一个现在卖得非常好、但是产品和技术却不怎样的项目,做一个只要一半价格的竞品。

比如 Mac 下有一个非常有名的写作软件,叫 Ulysses 。我试用了一下非常不错,但就是贵,283 RMB。后来看到了 Mweb ,光是免费的 Lite 版覆盖了 Ulysses 的主功能,完整版也才 98RMB,几乎没有思考就买下来了。

3. 做咨询

3.1 付费社群

除了 APP 外,我觉得收费群也是可以做的。

比如可以搞一个技术创业群,找一些创业成功的同学、做投资的同学、做法务的同学,面向想创业的同学开放,每人收个几百块的年费。

然后你在创业过程中遇到的问题,都可以有人解答,不会觉得是孤零零的一个人。

如果遇到了问题,群里的人可以解答;如果没遇到问题,那不是更好么。有种卖保险的感觉,哈哈哈。

比较好用的工具是 知识星球 也就是之前的小密圈。这个工具比较适合交流和讨论,长文比较痛苦。可以发布到其他地方,然后粘贴回来。

另一个靠谱的工具大概是微博的 V+ 会员。说它靠谱主要是它在微博上,所以等于整合了 「内容分发」→ 「新粉丝获取」 → 「付费用户转化」 的整个流程。

PS:交流型付费社群的一个比较难处理的事情是,很难平衡免费的粉丝和付费的社群之间的关系,所以我最后的选择是付费类的提供整块的内容,比如整理成册的小书、录制的实战视频等;而日常零碎的资料分享还是放到微博这种公开免费的平台。

3.2 专家平台

如果你在技术圈子里边小有名气,或者在某一个业务上特别精通,那么通过做咨询来挣钱是一种更轻松的方式。

和人在咖啡厅聊几个小时,几百上千块钱就到手了。

国内这方面的产品,我知道的有下边几个:

  • 在行: 这个是果壳旗下的,做得比较早,内容是全行业的,所以上边技术向的反而不多。
  • 缘创派: 缘创派的轻合伙栏目,主要面向创业者,适合喜欢感受创业氛围的技术专家们。
  • 极牛: 你可以认为这是一个程序员版本的「在行」,我浏览了下,虽然被约次数比在行要低不少,但专业性比较强,期望他们能尽快的推广开来吧。
  • 知加:这个项目是我参与的,面向程序员,类似「分答」的付费语音问答,刚开始内测,上边有一些硅谷科技公司的同学。感兴趣的可以看看。

做咨询虽然也是实名的,但和私活是完全不同的。

咨询的时间短,不会影响到正常的休息,更不会影响上班;

而且大部分公司是鼓励技术交流的,所以大家的接受度都很高。

4. 写文章

4.1 投稿

很多同学喜欢写技术博客,其实把文章投给一些网站是有稿费的。

比如 InfoQ,他们家喜欢收 3000~4000 字的深度技术文章;稿费是 200 元 / 篇。

虽然不算太多,但一篇长文的稿费也够买个入门级的 Cherry 键盘了。我喜欢 InfoQ 的地方是他们的版权要求上比较宽松。
文章在他们网站发布后,你可以再发布到自己博客的;而且文章可以用于出书,只要标明原发于 InfoQ 即可。

文章还可以发到微博、微信、简书等支持打赏的平台。个人建议是换个咱程序员自己的平台写文章。

更详细的说明见这里:https://www.infoq.cn/instruction

4.2 出版

顺便说一句,比起写文章,其实通过传统发行渠道出书并不怎么挣钱,我之前到手的版税是8%,如果通过网络等渠道销售,数字会更低。

出电子书收益会好一些,我之前写过一篇文章专门介绍:《如何通过互联网出版一本小书

以前一直写图文为主的书,用 Markdown 非常不错;但最近开始写技术教程类的书,发现 Markdown 不太够用了,最主要的问题有

  • ① 不支持视频标签,尤其是本地视频方案
  • ② 代码高亮什么的很麻烦
  • ③ 也没有 footer note、文内说明区域的预置。

这里要向大家严重推荐 Asciidoc,你可以把它看成一个增强版的 Markdown,预置了非常多的常用格式,而且 GitBook 直接就支持这个格式(只要把.md 搞成 .adoc 就好),Atom 也有实时预览插件。
用了一段时间,非常喜欢。

掘金小册

小册 是由掘金推出的付费文集产品。

我是小册的第一批作者,一路用下来还是很不错的。

文章格式直接采用 Markdown , 发布以后可以实时更新,保证内容的新鲜度,非常的方便。

小册的一般定价在 19~29,通用内容销量好的能过千,细分内容基本也能过百。

挣零花钱的话,是个非常不错的选择。

4.3 付费文集

最近一年有不少的付费文集产品出现,可以把它看成传统出版的一个网络版。

一般是写作十篇以内的系列文章,定价为传统计算机书的一半到三分之一。

付费文集产品通常是独家授权,所以在选择平台方面一定要慎重,不然一个好作品可能就坑掉了。

达人课

达人课 是 GitChat 旗下的付费文集产品,现在应该已经合并到 CSDN 了。

GitChat 的用户群不算大,但付费意愿还可以,大概因为内容就没有免费的🤣。

之前我上课的时候是提交完成以后的文档给编辑,由编辑同学手动上架。

感觉比较麻烦,尤其是修改错别字什么的。

小专栏

这个平台不熟……写到这里仅供参考。

5. 教学视频

应聘程序教学网站讲师的经验:应聘程序教学网站讲师,出视频+作业教程,平台按小时支付,这个不知道算不算挣零花钱,我算了一下去年,一年大概出 20 个小时视频,拿到手是不到 6 万的样子,平时就是周末花时间弄下。

在线教育起来以后,录制教学视频也可以赚钱了。

关于录制在线课程的收益,一直不为广大程序员所知。但最近和 51CTO 学院网易云课堂 的同学聊天,才发现一个优秀的 40~60 节的微专业课程,一年的收益比得上一线城市高级总监的收入。

难怪最近做培训的人这么多 😂

5.1 渠道和分成

大部分的平台合同有保密协议,所以不能对外讲。

网易云课堂Udemy 在公开的讲师注册协议中写明了分成,所以这里说一下。

网易云课堂

网易的课分三类:

  • 独立上架:等于网易提供平台(视频上传管理、用户管理、支付系统等),由你自己来负责营销。这个分成比例在 9:1 ,平台收取 10% 的技术服务费。我觉得非常划算。
  • 精品课:由网易帮你推广,但需要和他们签订独立的合同,会收取更多的分成。最麻烦的是,通常是独家授权。一旦签署了,就不能在其他平台上架课程了。
  • 微专业:这个是网易自己规划的课程体系,从课程的策划阶段就需要和他们深度沟通。也是网易推广力度最大、收益最大的一类课程。

方糖全栈课就放在网易平台上,觉得好的地方如下:

  • 支付渠道相对全,还支持花呗,这样对于我这种高价课就非常重要。苹果应用内购买课程渠道费用会被苹果扣掉 30%,好想关掉 🤣
  • 自带推广系统,愿意的话可以用来做课程代理系统。

Udemy

相比之下 Udemy 就很贵了,分成是 5:5 ;支付上国内用户只能通过信用卡或者银行卡绑 paypal 支付。但可以把课程推向全球。(但我英文还不能讲课🙃)

腾讯课堂没用过,欢迎熟悉的同学 PR 。

5.2 小课和大课

我个人喜欢把视频分成小课和大课两种。

因为视频虽然看起来时间短,但实际上要做好的话,背后要消耗的时间、要投入精力还是非常多的。

大课动不动就是几十上百个课时,绝大部分上班的程序员都没有时间来录制。

所以挣零花钱更适合做小课,这种课一般就几个小时,剪辑成 10 个左右的小课时,价格在几十百来块。

如果是自己专业纯熟的领域,一个长假就可以搞定。

5.3 表现形式

在课程的表现形式上,我个人更喜欢 designcode.io 这种 图文+视频 的模式,一方面是学习者可以快速的翻阅迅速跳过自己已经学会的知识;另一方面,会多出来 微博头条文章、微信公众号、知乎和简书专栏这些长文推广渠道。

当然,内容本身才是最核心的。现在那么多的免费视频,为什么要来买你的收费版?

其实现在绝大部分教学视频,往往都真的只是教学,和现实世界我们遇到的问题截然不同。里边都是一堆简化后的假项目,为了教学而刻意设计的。

这里和大家分享一个我之前想操作的想法。

就是在自己决定开始做一个开源项目后,用录屏软件把整个过程完完整整的录下来。开源的屏幕录制工具 OBS,1920*1080 的屏幕录成 FLV 格式,一个小时只需要 1G,一个 T 的移动硬盘可以录制上千小时,对一个中型项目来说也足够了。

等项目做完,就开源放到 GitHub,让大家先用起来。等迭代稳定后,再从录制的全量视频中剪辑出一系列的教程,整理出一系列的文章,放到网站上做收费课程。

这样做有几个好处:

  • 保证所有遇到的问题都是真实的,不是想象出来的,学习过这个课程的人,可以独立的将整个项目完整的实现。
  • 没有特意的录制过程,所以教程其实是软件开发的副产品,投入产出比更高。
  • 如果你的软件的确写得好,那么用过你软件的人可以成为你的客户或者推荐员。

5.4 后续

今年我录制 方糖全栈课 的时候就采用了上边这个思路,效果还不错,不过有几个小问题:

  • 连续性。录着视频写代码总会有一种潜在焦虑,平时经常写一会儿休息一会儿,录像时就会留下大段的空白,有点浪费空间。当然这个主要是心理上的。
  • 录音。录音的问题更大一些。因为一个长期项目很难一直处于一个安静的环境,另外基础课录制可能需要大量的讲解,几个小时写下来嗓子哑了 🤣 。
  • 最后的解决方式是剪辑的时候重新配音,不过需要注意音画同步问题。

5.5 硬件

硬件上边,最好买一个用来支撑话筒的支架。不要用手直接握着话筒来录,这样就不会有电流声(或者很小)。

外接声卡我用的是 XOX , 在 Mac 下边效果挺好,但不知道为啥在 Windows 上回声比较大(当然也可能是系统设置的原因)。


2019年的做课总结:《如何做一个日入十万的技术课程》

5.6 软件

如果是没有太多界面切换的课程,那可以使用 keynote 自带的录音。在其他环境里边的演示的视频可以直接粘贴到 keynote 里面来播放。

但是当你有很多的外部界面的时候,就需要录屏了。mac 上可以直接用 quicktime 来录制。文件,新建里边选 record screen 就好。

我录全栈课的时候,因为会在三个操作系统上录一些界面,所以我选择了 obs。虽然这个工具主打的是直播,但实际上它的录制功能也还是挺不错的。

剪辑的话,用 mac 的 imovie 基本就够了,主要用到的功能就是分割片段,然后把不要的删掉。音频去下底噪。部分等待时间过长的片段加速下。当然 adobe 家的也行,就是贵。

6. 内部推荐和猎头推荐

如果你在 BAT 等一流互联网公司工作,如果你有一帮志同道合的程序员朋友,那么基本上每隔几个月你应该就会遇到有想换工作的同事和朋友,所以千万别错过你挣推荐费的大好时机。

一般来讲,公司内部推荐的钱会少一些,我见过的 3000~6000 的居多。
但因为是自己公司,会比较靠谱,所以风险小一些。经常给公司推荐人才,还会提升老大多你的好感度,能优先就优先吧。

比起内部推荐,猎头推荐的推荐费则会多很多。
一个 30 万年薪的程序员,成功入职后差不多可以拿到 1万RMB 的推荐费。但猎头渠道的问题在于对简历质量要求非常高,有知名公司背景的才容易成单;回款周期又特别长,一般要入职过了试用期以后才能拿到全部推荐费,得小半年。

7. 小结

工作两三年的程序员,如果不是非常非常需要钱,我还是建议先做好本职工作,把业余时间用来学习技术、深入技术,因为前几年是技术提升非常快的时期,提高技术带来的财富往往是翻倍增长的,也是这些小小的零花钱比不了的。

天下熙熙皆为利来,天下攘攘皆为利往,钱财所到之处,必然人头攒动、摩肩接踵。当大家躬身入局之时,定会发现存在僧多肉少的尴尬局面。

学会挣钱是一件非常重要的事情,它会让你了解商业是如何运作的,帮助你理解公司的产品逻辑、以及为你可能的技术创业打下坚实的基础。

所以我鼓励大家多去挣零花钱,最好各种姿势都试试,会有意想不到的乐趣。

本文大部分内容来自于开源项目:程序员如何优雅的挣零花钱:https://github.com/easychen/howto-make-more-money

笔者丰富了很多内容,并重新进行了排版优化。

8. 几点思维与认知

作为程序员来讲,我一直都认为:

技术重要吗?重要!非常重要!毕竟这毕竟是谋生的技能。但是技术以外的能力,程序员真的都应该学一学。技术一般也只能用 3 - 10 年而已,但是相比于人的一生,真的非常短。技术以外的东西,绝对能够提高一个人的高度,陷在技术里,只会让自己的思维越来越窄。

8.1 技术只是一个工具

我们要深刻的认识到技术仅仅只是一个工具。当程序员是学会使用了工具,用这个工具去打工赚钱。其实,我们应该脱离出这个思维陷阱出来,学会让工具为你打工,而不是你用着这个工具去给别人打工。

举个简单例子:别人用 Python 写了一个爬虫,天天爬美女图片,然后搭建了一个美图的网站,上面放上了 Google 的广告联盟,每天爬虫工具都会定时去其他网站去爬美女图片,放到自己的网站上,而自己的网站久而久之就会很多流量进入,然后广告收入就越来越多。

所以,程序员应该学会让工具为你打工,而不是你用工具给别人打工。

8.2 接私活不挣钱,学会利用边际成本递减

很多程序员想:我拥有技术,我可以开发软件,我可以利用业余时间接私活挣钱。

现实情况是接私活非常累,看似自己挣钱了,其实与自己投入的时间和精力相比,你根本就没有挣钱。接过私活的同学都应该有这种感受吧?

就是大家应该开发一个软件,比如:小程序商城,你开发出来了,你这套程序员可以重复卖。你开发第一个小程序商城的成本可能是 2 万,你卖给第一个客户 5000 ,你可能感觉自己赔了,但是,你这套代码可以重复卖很多人啊,卖到第四个人的时候,收回成本,卖到第 6 个人,就是赚钱,等卖到 10 个人的时候,你哪里还会有成本呢?

这就是边际成本递减效应。大家应该学会重复利用软件的价值,可以做到软件的边际成本为 0 。

8.3 用钱买别人的时间是最划算的买卖

第一层境界——打工者:同一份时间,出售一次。

第二层境界——成长者:同一份时间,出售两次。

第三层境界——IP者:同一份时间,出售多次。

到了第三层境界,你发现你自己的时间价值已经到顶了,同一份时间已经出售多次了,还能怎么提升?

第四层境界——资本家:用钱买别人的时间,创造了价值,然后再卖出去。

9. 最后


笔者博客首更地址:https://github.com/biaochenxuying/blog

何以解忧?唯有暴富!

如果本文对你哪怕有一丁点帮助,可以点个赞,你的肯定是我继续写出好作品的最大动力。

读者们有什么建议,可以点击上面链接进去评论留言,还可以添加图片来留言哦。

提示:想获取文中外链,点击 原文阅读 即可,或者在公众号后台回复 零花钱 亦可。

1. GitHub 上能挖矿的神仙技巧 - 如何发现优秀开源项目
2. 150+ 本技术类精华电子书开源了,包括前端、后端、数据结构与算法、计算机基础、设计模式、数据库等书籍

另外,关注公众号:全栈修炼,发送 1024 可领取免费视频资料。

发送 电子书,可领取 160+ 本精华技术电子书。

转发、在看 就是最大的支持 👇​

查看原文

赞 51 收藏 32 评论 1

jayzou 关注了问题 · 2020-02-19

难!我逐个给你div,让你填充到桌面,怎么实现?

div从数组里按顺序取出来,逐个填充到桌面上。
如何实现?

已知每个div 的四个角的顶点坐标,和div的宽高。
求整理后每个div的新坐标。
如下图:
image.png
image.png

两种路线:
打个比方,你和同伴是装车,你站在车上码箱子,他在车下递箱子。
第一种:箱子是由同伴指定传递给你的,你只负责在车上码箱子。
第二种:你指定箱子,然后让同伴递给你,你再码箱子。

关注 4 回答 1

jayzou 赞了回答 · 2020-02-19

解决await为什么可以直接在console使用?

Chrome 支持顶层的await

在ecma262里这还是一个提案

关注 2 回答 1

认证与成就

  • 获得 595 次点赞
  • 获得 8 枚徽章 获得 1 枚金徽章, 获得 1 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-05-26
个人主页被 3.4k 人浏览