题叶

题叶 查看完整档案

上海编辑  |  填写毕业院校积梦  |  前端 编辑 tiye.me 编辑
编辑

ClojureScript 爱好者.

个人动态

题叶 发布了文章 · 11月20日

论前端框架组件状态抽象方案, 基于 ClojureScript 的 Respo 为例

Respo 是本文作者基于 ClojureScript 封装的 virtual DOM 微型 MVC 方案.
本文使用的工具链基于 Clojure 的, 会有一些阅读方面的不便.

背景

Backbone 以前的前端方案在文本作者的了解之外, 本文作者主要是 React 方向的经验.
在 Backbone 时期, Component 的概念已经比较清晰了.
Component 实例当中保存组件的局部状态, 而组件视图根据这个状态来进行同步.
到 React 出现, 基本形成了目前大家熟悉的组件化方案.
每个组件有局部状态, 视图自动根据状态进行自动更新, 以及专门抽象出全局状态.

React 之外还有 MVVM 方案, 不过本文作者认为 MVVM 偏向于模板引擎的强化方案.
MVVM 后续走向 Svelte 那样的静态分析和代码生成会更自然一些, 而不是运行时的 MVC.

React 历史方案

React 当中局部状态的概念较为明确, 组件挂载时初始化, 组件卸载时清除.
可以明确, 状态是保存在组件实例上的. Source of Truth 在组件当中.
与此相区别的方案是组件状态脱离组件, 存储在全局, 跟全局状态类似.

组件内存储的状态方便组件自身访问和操作, 是大家十分习惯的写法.
以往的 this.state 和现在的 useState 可以很容易访问全局状态.
而 React 组件中访问全局状态, 需要用到 Context/Redux connect 之类的方案,
有使用经验的会知道, 这中间会涉及到不少麻烦, 虽然大部分会被 Redux 封装在类库内部.

Respo 是基于 ClojureScript 不可变数据实现的一个 MVC 方案.
由于函数式编程隔离副作用的一贯的观念, 在组件局部维护组件状态并不是优雅的方案.
而且出于热替换考虑, Respo 选择了全局存储组件状态的方案, 以保证状态不丢失. (后文详述)

本文作者没有对 React, Vue, Angular 等框架内部实现做过详细调研,
只是从热替换过程的行为, 推断框架使用的就是普通的组件存储局部状态的方案.
如果有疑点, 后续再做讨论.

全局状态和热替换

前端由 react-hot-loader 率先引入热替换的概念. 此前在 Elm 框架当中也有 Demo 展示.
由于 Elm 是基于代数类型函数式编程开发的平台, 早先未必有明确的组件化方案, 暂不讨论.
react-hot-loader 可以借助 webpack loader 的一些功能对代码进行编译转化,
在 js 代码热替换过程中, 先保存组件状态, 在 js 更新以后替换组件状态,
从而达到了组件状态无缝热替换这样的效果, 所以最初非常惊艳.
然而, 由于 React 设计上就是在局部存储组件状态, 所以该方案后来逐渐被废弃和替换.

从 react-hot-loader 的例子当中, 我们得到经验, 代码可以热替换, 可以保存恢复状态.
首先对于代码热替换, 在函数式编程语言比如 Elm, ClojureScript 当中, 较为普遍,
基于函数式编程的纯函数概念, 纯函数的代码可以通过简单的方式无缝进行替换,
譬如界面渲染用到函数 F1, 但是后来 F1 的实现替换为 F2, 那么只要能更新代码,
然后, 只要重新调用 F1 计算并渲染界面, 就可以完成程序当中 F1 的替换, 而没有其他影响.

其次是状态, 状态可以通过 window.__backup_states__ = {...} 方式保存和重新读取.
这个并没有门槛, 但是这种方案, 怕的是程序当中有点大量的局部状态, 那么编译工具是难以追踪的.
而函数式编程使用的不可变数据特性, 可以大范围规避此类的局部状态,
而最终通过一些抽象, 将可变状态放到全局的若干个通过 reference 维护的状态当中.
于是上述方案才会有比较强的实用性. 同时, 全局状态也提供更好的可靠性和可调试性.

抽象方法

Respo 是基于 cljs 独立设计的方案, 所以相对有比较大的自由度,
首先, 在 cljs 当中, 以往在 js 里的对象数据, 要分成两类来看待:

  • 数据. 数据就是数据, 比如 1 就是 1, 它是不能改变的,
    同理 {:name "XiaoMing", :age 20} 是数据, 也是不可以改变的.
    但这个例子中, 同一个人年龄会增加呀, 程序需如何表示年龄的增加呢,
    那么就需要创建一条新的数据, {:name "XiaoMing", :ago 21} 表示新增加的.
    这是两条数据, 虽然内部实现可以复用 :name 这个部分, 但是它就是两条数据.
  • 状态. 状态是可以改变的, 或者说指向的位置是可以改变的,
    比如维护一个状态 A 为<Ref {:name "XiaoMing", :age 20}>,
    A 就是一个状态, 是 Ref, 而不是数据, 需要获取数据要用 (deref A) 才能得到.
    同理, 修改数据就需要 (reset! A {...}) 才能完成了.
    所以 A 就像是一个箱子, 箱子当中的物品是可以改变的, 一箱苹果, 一箱硬盘,
    你有一个苹果, 那就是一个苹果, 你有一个箱子, 别人在箱子里可能放苹果, 也可能放硬盘.

基于这样的数据/状态的区分, 我们就可以知道组件状态在 cljs 如何看到了.
可以设置一个引用 S, 作为一个 Ref, 内部存储着复杂结构的数据.
而程序在很多地方可以引用 S, 但是需要 (deref S) 才能拿到具体的数据.
而拿到了具体的数据, 那就是数据了, 在 cljs 里边是不可以更改的.

(defonce S (atom {:user {:name "XiaoMing", :age 20}}))

便于跟组件的树形结构对应的话, 就会是一个很深的数据结构来表示状态,

(defonce S (atom {
   :states {
     :comp-a {:data {}}
     :comp-b {:data {}}
     :comp-c {:data {}
              :comp-d {:data {}}
              :comp-e {:data {}}
              :comp-f {:data {}
                       :comp-g {:data {}}
                       :comp-h {:data {}}}}}}))

定义好以后, 我们还要解决后面的问题,

  • 某个组件 C 怎样读取到 S 的状态?
  • 某个组件 C 怎样对 S 内的状态进行修改?

基于 mobx 或者一些 js 的方案当中, 拿到数据就是获取到引用, 然后直接就能改掉了.
对于函数式编程来说, 这是不能做到的一个想法. 或者说也不可取.
可以随时改变的数据没有可预测性, 你创建术语命名为 X1, 可以改的话你没法确定 X1 到底是什么.
在 cljs 当中如果是 Ref, 那么会知道这是一个状态, 会去监听, 使用的时候会认为是有新的值.
但是 cljs 中的数据, 拿到了就认为是不变了的.
所以在这样的环境当中, 修改全局状态要借助其他一些方案. 所以上边是两个问题.

当然基于 js 的使用经验, 或者 lodash 的经验, 我们知道修改一个数据思路很多,
借助一个 path 的概念, 通过 [:states :comp-a] 就可以修改 A 组件的数据,
同理, 通过 [:states :comp-c :comp-f :comp-h] 可以修掉 H 组件的数据.
具体修改涉及 Clojure 的内部函数, 在 js 当中也不难理解, lodash 就有类似函数.

本文主要讲的是 Respo 当中的方案, 也就是基于这个 cljs 语言的方案.
这个方案当中基本上靠组件 props 数据传递的过程来传递数据的,
比如组件 A 会拿到 {:data {}} 这个部分, A 的数据就是 {},
而组件 C 拿到的是包含其子组件的整体的数据:

{:data {}
 :comp-d {:data {}}
 :comp-e {:data {}}
 :comp-f {:data {}
          :comp-g {:data {}}
          :comp-h {:data {}}}}

尽管 C 实际的数据还是它的 :data 部分的数据, 也还是 {}.
不过这样一步步获取, 组件 H 也就能获取它的数据 {} 了.

在修改数据的阶段, 在原来的 dispatch! 操作的位置, 就可以带上 path 来操作,

(dispatch! :states [[:comp-c :comp-f :comp-h], {:age 21}])

在处理数据更新的位置, 可以提取出 path 和 newData 在全局状态当中更新,
之后, 视图层重新渲染, 组件再通过 props 层层展开, H 就得到新的组件状态数据 {:age 21} 了.

从思路上说, 这个是非常清晰的. 有了全局状态 S, 就可以很容易处理成热替换需要的效果.

使用效果

实际操作当中会有一些麻烦, 比如这个 [:comp-c :comp-f :comp-h] 怎么拿到?
这在实际当中就只能每个组件传递 props 的时候也一起传递进去了. 这个操作会显得比较繁琐.
具体这部分内容, 本文不做详细介绍了, 从原理出发, 办法总有一些, 当然是免不了繁琐.
cljs 由于是 Lisp, 所以在思路上就是做抽象, 函数抽象, 语法抽象, 减少代码量.
写出来的效果大体就是这样:

(defonce *global-states {:states {:cursor []}})

(defcomp (comp-item [states]
 (let [cursor (:cursor states)
       state (or (:data states) {:content "something"})]
   (div {}
    (text (:content state))))))

(defcomp comp-list [states]
  (let [cursor (:cursor states)
        state (or (:data states) {:name "demo"})]
   (div {}
      (text (:name "demo"))
      (comp-item (>> states "task-1"))
      (comp-item (>> states "task-2")))))

其中传递状态的代码的关键是 >> 这个函数,

(defn >> [states k]
  (let [cursor, (or (:cursor states) [])]
    (assoc (get states k)
           :cursor
           (conj cursor k))))

它有两个功能, 对应到 states 的传递, 以及 cursor 的传递(也就是 path).
举一个例子, 比如全局拿到的状态的数据是:

{:data {}
 :comp-d {:data {}}
 :comp-e {:data {}}
 :comp-f {:data {}
          :comp-g {:data {}}
          :comp-h {:data {:h 0}}}}

我们通过 (>> states :comp-f) 进行一层转换, 获取 F 组件的状态数据,
同时 path 做了一次更新, 从原来的没有(对应 []) 得到了 :comp-f:

{:data {}
 :cursor [:comp-f]
 :comp-g {:data {}}
 :comp-h {:data {:h 0}}}

到下一个组件传递参数时, 通过 (>> states :comp-h) 再转化, 取得 H 的状态数据,
同时对应给 H 的 cursor 也更新成了 [:comp-f :comp-h]:

{:data {:h 0}
 :cursor [:comp-f :comp-h]}

通过这样的方式, 至少在传递全局状态上不用那么多代码了.
同时也达到了一个效果, 对应组件树, 拿到的就是对应自身组件树(包含子组件)的数据.

当然从 js 用户角度看的话, 这种方式是有着一些缺陷的,
首先代码量还是有点多, 初始化状态写法也有点怪, 需要用到 or 手动处理空值,
而 React 相比, 这个方案的全局数据, 不会自动清空, 就可能需要手动清理数据.
另外, 这个方案对于副作用的管理也不友好, 譬如处理复杂的网络请求状态, 就很麻烦.
由于 cljs 的函数式编程性质, 本文作者倾向于认为那些情况还会变的更为复杂, 需要很多代码量.

就总体来说, 函数式编程相对于 js 这类混合范式的编程语言来说, 并不是更强大,
当然 Lisp 设计上的先进性能够让语言非常灵活, 除了函数抽象, macro 抽象也能贡献大量的灵活度,
但是在数据这一层来说, 不可变数据是一个限制, 而不是一个能力, 也就意味着手段的减少,
减少这个手段意味着数据流更清晰, 代码当中状态更为可控, 但是代码量会因此而增长.
那么本文作者认为最终 js 的方式是可以造出更简短精悍的代码的, 这是 Lisp 方案不擅长的.
而本文的目的, 限于在 cljs 方案和热替换的良好配合情况下, 提供一种可行的抽象方式.

查看原文

赞 2 收藏 1 评论 0

题叶 赞了文章 · 10月18日

WebGL 初印象

webgl.jpg

最近由于工作需要,开始学习 WebGL 相关的知识。这篇文章的目的就是记录下学习过程中的一些知识概念,并实现一个简单的 demo,帮助大家快速理解 webgl 的概貌并上手开发。最后会分享自己对于 webgl 的几点想法,给有需要的人提供参考。

WebGL 全称 Web Graphics Library,是一种支持 3D 的绘图技术,为 web 开发者提供了一套 3D 图形相关的接口。通过这些接口,开发者可以直接跟 GPU 进行通信。

WebGL 程序分为 2 部分:

  • 使用 Javascript 编写的运行在 CPU 的程序
  • 使用 GLSL 编写的运行在 GPU 的着色器程序

着色器程序接收 CPU 传过来的数据,并进行一定处理,最终渲染成丰富多彩的应用样式。

渲染流程

WebGL 能绘制的基本图元只有 3 种,分别是线段三角形,对应了物理世界中的点线面。所有复杂的图形或者立方体,都是先用组成基本结构,然后用三角形将这些点构成的平面填充起来,最后由多个平面组成立方体。

所以,我们需要从构建顶点数据开始。顶点坐标一般还需要经过一些转换步骤,才能够变成符合裁剪坐标系的数据。这些转换步骤,我们可以用矩阵来表示。把变换矩阵和初始顶点信息传给 GPU,大致处理步骤如下:

  1. 顶点着色器:根据变换矩阵和初始顶点信息进行运算,得到裁剪坐标。这个计算过程也可以放到 js 程序中做,但是这样就不能充分利用 GPU 的并行计算优势了。
  2. 图元装配:使用三角形图元装配顶点区域。
  3. 光栅化:用没有颜色的像素填充图形区域。
  4. 片元着色器:为像素着色。

我们可以用 GLSL 编程控制的是顶点着色器片元着色器这 2 步。这一套类似于流水线的渲染过程,在业界被称为渲染管线

process
(图片来自掘金小册:WebGL 入门与实践,这是一份不错的入门学习资料,推荐一下)

开始创作

这一部分,我会带领大家一步一步创建一个会旋转的正方体,帮助大家上手 webgl 开发。

准备工作

WebGL 开发的准备工作类似于 canvas 开发,需要准备一个 html 文档,并包含<canvas>标签,只不过调用getContext时传入的参数不是2d,而是webgl

另外,webgl 还需要使用 GLSL 进行顶点着色器和片元着色器编程。

下面是准备好的 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>
  <canvas id="canvas"></canvas>
  <script>
    // 顶点着色器代码
    const vertexShaderSource = `
      // 编写 glsl 代码
    `;
    // 片元着色器代码
    const fragmentShaderSource = `
      // 编写 glsl 代码
    `;
    
    // 根据源代码创建着色器对象
    function createShader(gl, type, source) {
      const shader = gl.createShader(type);
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      return shader;
    }
    
    // 获取 canvas 并设置尺寸
    const canvas = document.querySelector('#canvas');
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    // 获取 webgl 上下文
    const gl = canvas.getContext('webgl');
    
    // 创建顶点着色器对象
    const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
    // 创建片元着色器对象
    const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
    // 创建 webgl 程序对象
    const program = gl.createProgram();
    // 绑定顶点着色器
    gl.attachShader(program, vertexShader);
    // 绑定片元着色器
    gl.attachShader(program, fragmentShader);
    // 链接程序
    gl.linkProgram(program);
    // 使用程序
    gl.useProgram(program);
  </script>
</body>
</html>

几乎每一行代码都加了注释,应该能看懂了。这里再单独说一下着色器源代码,上面的示例中,我们预留了一个字符串模板,用于编写着色器的 GLSL 代码。实际上,只要在创建着色器对象的时候,能把着色器代码作为字符串传入createShader方法就行,不管是直接从 js 变量中获取,还是通过 ajax 从远端获取。

目前为止,我们已经开始调用了 webgl 相关的 js api(各 api 具体用法请翻阅MDN),但是这些代码还不能渲染出任何画面。

这部分我们尝试渲染一个固定位置的点。先从顶点着色器开始:

void main() {
  gl_PointSize = 5.0;
  gl_Position = vec4(0, 0, 0, 1);
}

这部分是 GLSL 代码,类似于 C 语言,解释下含义:

  • 要执行的代码包裹在main函数中
  • gl_PointSize 表示点的尺寸
  • gl_Position是全局变量,用于定义顶点的坐标,vec4表示一个四位向量,前三位是x/y/z轴数值,取值区间均为0-1,最后一位是齐次分量,是 GPU 用来从裁剪坐标系转换到NDC坐标系的,我们设置为 1 就行。

接着写片元着色器:

void main() {
  gl_FragColor = vec4(1, 0, 0, 1);
}

gl_FragColor表示要为像素填充的颜色,后面的四维向量类似于 CSS 中的rgba,只不过rgb的值从0-255等比缩放为0-1,最后一位代表不透明度。

最后,我们来完善一下 js 代码:

// 设置清空 canvas 画布的颜色
gl.clearColor(1, 1, 1, 1);
// 清空画布
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制一个点
gl.drawArrays(gl.POINTS, 0, 1);

clearColor设置为1,1,1,1,相当于rgba(255, 255, 255, 1),也就是白色。渲染后效果如下:

dot

三角形

成功渲染一个点之后,我们已经对于 webgl 的渲染流程有一定了解。三角形也是 webgl 基本图元之一,要渲染三角形,我们可以指定三角形 3 个顶点的坐标,然后指定绘制类型为三角形。

之前的示例只渲染一个顶点,用gl_Position接受一个顶点的坐标,那么如何指定 3 个顶点坐标呢?这里我们需要引入缓冲区的机制,在 js 中指定 3 个顶点的坐标,然后通过缓冲区传递给 webgl。

先改造下顶点着色器:

// 设置浮点数精度
precision mediump float;
// 接受 js 传过来的坐标
attribute vec2 a_Position;
void main() {
  gl_Position = vec4(a_Position, 0, 1);
}

attribute可以声明在顶点着色器中,js 可以向attribute传递数据。这里我们声明了一个二维向量a_Position,用来表示点的x/y坐标,z轴统一为 0。

另外,我们把gl_PointSize的赋值去掉了,因为我们这次要渲染的是三角形,不是点。

片元着色器暂时不需要改动。

接着我们改造下 js 部分。

const points = [
  -0.5, 0, // 第 1 个顶点
  0.5, 0, // 第 2 个顶点
  0, 0.5 // 第 3 个顶点
];

// 创建 buffer
const buffer = gl.createBuffer();
// 绑定buffer为当前缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// 获取程序中的 a_Position 变量
const a_Position = gl.getAttribLocation(program, 'a_Position');
// 激活 a_Position
gl.enableVertexAttribArray(a_Position);
// 指定 a_Position 从 buffer 获取数据的方式
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
// 给 buffer 灌数据
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.STATIC_DRAW);
// 设置清空 canvas 画布的颜色
gl.clearColor(1, 1, 1, 1);
// 清空画布
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制三角形
gl.drawArrays(gl.TRIANGLES, 0, points.length / 2);

这样,一个三角形就绘制出来了,看到的效果应该是这样:

triangle

正方形

正方形并不是 WebGL 的基本图元之一,我们要如何绘制呢?答案就是用 2 个三角形拼接。在上面绘制三角形的代码基础上改动就很容易了,把 3 个顶点改为 6 个顶点,表示 2 个三角形就行

const points = [
  -0.2, 0.2, // p1
  -0.2, -0.2, // p2
  0.2, -0.2, // p3
  0.2, -0.2, // p4
  0.2, 0.2, // p5
  -0.2, 0.2 // p6
];

效果如下:
rect

可以看到,p3 和 p4,p1 和 p6,其实是重合的。这里可以使用索引来减少重复点的声明。我们再次改造下 js 代码

const points = [
  -0.2, 0.2, // p1
  -0.2, -0.2, // p2
  0.2, -0.2, // p3
  0.2, 0.2, // p4
];

// 根据 points 中的 index 设置索引
const indices = [
  0, 1, 2, // 第一个三角形
  2, 3, 0 // 第二个三角形
];

// 创建 buffer
const buffer = gl.createBuffer();
// 绑定buffer为当前缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// 获取程序中的 a_Position 变量
const a_Position = gl.getAttribLocation(program, 'a_Position');
// 激活 a_Position
gl.enableVertexAttribArray(a_Position);
// 指定 a_Position 从 buffer 获取数据的方式
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
// 给 buffer 灌数据
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.STATIC_DRAW);

// 创建索引 buffer
const indicesBuffer = gl.createBuffer();
// 绑定索引 buffer
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indicesBuffer);
// 灌数据
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);

// 设置清空 canvas 画布的颜色
gl.clearColor(1, 1, 1, 1);
// 清空画布
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制三角形
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);

效果与之前用 6 个点一样。千万别小看这里 2 个点的优化,在大型项目中,复杂图形往往由成千上万个点构成,使用索引能对内存占用进行有效的优化。

正方体

正方体由 6 个正方形组成,共 8 个顶点,我们只要构建出这 8 个顶点的位置,然后用三角形图元把它绘制出来就行了。为了加以区分各个平面,我们使用不同的颜色的来绘制每个面。

先改动下顶点着色器,用来接收顶点的颜色信息。

// 设置浮点数精度
precision mediump float;
// 接受 js 传过来的坐标
attribute vec3 a_Position;
// 接收 js 传过来的颜色
attribute vec4 a_Color;
// 透传给片元着色器
varying vec4 v_Color;
void main() {
  gl_Position = vec4(a_Position, 1);
  v_Color = a_Color;
}

这里新增了 2 个变量,a_Color类似于a_Position,可以接收 js 传过来的顶点颜色信息,但是颜色最终是在片元着色器中使用的,所以我们要通过v_Color透传出去。varying类型变量就是用于在顶点着色器和片元着色器之间传递数据。另外,a_Position我们改为了三维向量,因为需要制定 z 坐标。

接下来是片元着色器:

// 设置浮点数精度
precision mediump float;
// 接收顶点着色器传来的颜色信息
varying vec4 v_Color;
void main() {
  gl_FragColor = v_Color / vec4(255, 255, 255, 1);
}

除了接收v_Color之外,我们还把v_Color进行了处理,这样在 js 中我们就可以使用最原始的rgba值,然后在 GPU 中计算得到真正的gl_FragColor,充分利用了 GPU 的并行计算优势。

现在,我们可以在 js 中构建正方体的顶点信息了。

/**
 * 创建一个立方体,返回 points,indices,colors
 * 
 * @params width 宽度
 * @params height 高度
 * @params depth 深度
 */
function createCube(width, height, depth) {
  const baseX = width / 2;
  const baseY = height / 2;
  const baseZ = depth / 2;

  /*
        7 ---------- 6
       /|          / |
      / |         /  | 
    3 --|-------- 2  |
    |   4 --------|- 5
    |  /          |  /
    | /           | / 
    |/            |/ 
    0 ----------- 1
  */
  const facePoints = [
    [-baseX, -baseY, baseZ], // 顶点0
    [baseX, -baseY, baseZ], // 顶点1
    [baseX, baseY, baseZ], // 顶点2
    [-baseX, baseY, baseZ], // 顶点3
    [-baseX, -baseY, -baseZ], // 顶点4
    [baseX, -baseY, -baseZ], // 顶点5
    [baseX, baseY, -baseZ], // 顶点6
    [-baseX, baseY, -baseZ], // 顶点7
  ];
  const faceColors = [
    [255, 0, 0, 1], // 前面
    [0, 255, 0, 1], // 后面
    [0, 0, 255, 1], // 左面
    [255, 255, 0, 1], // 右面
    [0, 255, 255, 1], // 上面
    [255, 0, 255, 1] // 下面
  ];
  const faceIndices = [
    [0, 1, 2, 3], // 前面
    [4, 5, 6, 7], // 后面
    [0, 3, 7, 4], // 左面
    [1, 5, 6, 2], // 右面
    [3, 2, 6, 7], // 上面
    [0, 1, 5, 4], // 下面
  ];

  let points = [];
  let colors = [];
  let indices = [];

  for (let i = 0; i < 6; i++) {
    const currentFaceIndices = faceIndices[i];
    const currentFaceColor = faceColors[i];
    for (let j = 0; j < 4; j++) {
      const pointIndice = currentFaceIndices[j];
      points = points.concat(facePoints[pointIndice]);
      colors = colors.concat(currentFaceColor);
    }
    const offset = 4 * i;
    indices.push(offset, offset + 1, offset + 2);
    indices.push(offset, offset + 2, offset + 3);
  }

  return {
    points, colors, indices
  };
}

const { points, colors, indices } = createCube(0.6, 0.6, 0.6);

// 下面与绘制正方形基本一致,仅需增加 colors 的传递逻辑即可

这样绘制出来的图形效果是:

rect1

这里有 2 个问题:

  • Q:为什么是长方形,而不是正方体?

    • A:GPU 拿到赋值给gl_Position的值之后,除了把裁剪坐标转换成NDC坐标之外,还会根据画布的宽高进行一次视口变换,画布的宽高比不同,渲染出来的效果就不同。要解决这个问题,需要使用投影变换对坐标先处理一道。经过投影变换,我们再任何尺寸的画布上看到的都会是一个正方形,也就是正方体的一个面,这时候我们再让正方体旋转起来,就可以看到它的所有面了。
  • Q:根据设置的颜色,前面对应的色值是rgba(255, 0, 0, 1),也就是红色,为什么看到的是绿色?

    • A:裁剪坐标系遵循左手坐标系,也就是 z 轴正方向是指向屏幕里面,所以这里我们看到的其实是正方体的后面,就是rgba(0, 255, 0, 1)绿色了。

接下来我们增加一些矩阵计算工具,用于计算正交投影旋转等效果对应的坐标。

先修改下顶点着色器,增加一个变量用于引入坐标转换矩阵

// 设置浮点数精度
precision mediump float;
// 接受 js 传过来的坐标
attribute vec3 a_Position;
// 接收 js 传过来的颜色
attribute vec4 a_Color;
// 透传给片元着色器
varying vec4 v_Color;
// 转换矩阵
uniform mat4 u_Matrix;
void main() {
  gl_Position = u_Matrix * vec4(a_Position, 1);
  v_Color = a_Color;
}

矩阵计算工具我们直接引入别人写好的:

<script data-original="matrix.js"></script>
<script>
  // 前面的代码都一样,我就不重复贴了
  // 增加如下计算矩阵代码
  const aspect = canvas.width / canvas.height;
  const projectionMatrix = matrix.ortho(-aspect * 4, aspect * 4, -4, 4, 100, -100);
  const dstMatrix = matrix.identity();
  const tmpMatrix = matrix.identity();
  let xAngle = 0;
  let yAngle = 0;
  
  function deg2radians(deg) {
      return Math.PI / 180 * deg;
    }
  
  gl.clearColor(1, 1, 1, 1);
  const u_Matrix = gl.getUniformLocation(program, 'u_Matrix');
  
  function render() {
    xAngle += 1;
    yAngle += 1;
    // 先绕 Y 轴旋转矩阵
    matrix.rotationY(deg2radians(yAngle), dstMatrix);
    // 再绕 X 轴旋转
    matrix.multiply(dstMatrix, matrix.rotationX(deg2radians(xAngle), tmpMatrix), dstMatrix);
    // 模型投影矩阵
    matrix.multiply(projectionMatrix, dstMatrix, dstMatrix);
    // 给 GPU 传递矩阵
    gl.uniformMatrix4fv(u_Matrix, false, dstMatrix);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);
    // 让立方体动起来
    requestAnimationFrame(render);
  }
  
  render();
</script>

效果如下:

ball

完整代码请参考这里

谈谈感受

  • 原生的 webgl 编程还是比较繁琐,好在业内已经有一些优秀的库可以直接用,比如Three.jsBabylon之类。如果只是为了完成效果,直接引入库就可以了。但是如果是为了深入学习 webgl 开发,还是要了解原生的写法。
  • 要想学好 webgl 开发,只知道 api 和调用流程是不行的,还需要学习计算机图形学、线性代数等,明白各个几何变换如何用矩阵表示出来。例如上面的正交投影变换,应该有很多人没看懂。
  • WebGL 是基于 OpenGL 的,但是现在又出现了一个 Vulkan,大有取代 OpenGL 的意思。我觉得大可不必惊慌,还是要打好自己的基础,不管编程语言如何变化,计算机图形学、线性代数等基础学科知识不会变。
  • WebGL 开发目前在前端领域还属于偏小众的技术,但是随着 5G 的发展,未来应用的交互形式还会不断进化,3d 迟早会变为常态,到那时 3d 效果开发应该会成为前端的必备技能。
查看原文

赞 4 收藏 3 评论 1

题叶 发布了文章 · 7月31日

用 Lilac Parser 代替正则来抓取文本的例子

lilac-parser 是我用 ClojureScript 实现的一个库, 可以做一些正则的功能.
看名字, 这个库设计的时候更多是一个 parser 的思路,
从使用来说, 当做一个正则也是比较顺的. 虽然不如正则简短明了.
正则的缺点主要是基于字符串形态编写, 需要转义, 规则长了就不好维护了.
而 lilac-parser 的方式, 就挺容易进行组合的, 我这边举一些例子

首先是 is+ 这个规则, 进行精确匹配,

(parse-lilac "x" (is+ "x"))      ; {:ok? true, :rest nil}
(parse-lilac "xyz" (is+ "xyz"))  ; {:ok? true, :rest nil}
(parse-lilac "xy" (is+ "x"))     ; {:ok? false}
(parse-lilac "xy" (is+ "x"))     ; {:ok? true, :rest ("y")}
(parse-lilac "y" (is+ "x"))      ; {:ok? false}

可以看到, 头部匹配上的表达式, 都返回了 true.
后边是否还有其他内容, 需要通过 :rest 字段再去单独判断了.

当然精确匹配比较简单, 然后是选择匹配,

(parse-lilac "x" (one-of+ "xyz"))  ; {:ok? true}
(parse-lilac "y" (one-of+ "xyz"))  ; {:ok? true}
(parse-lilac "z" (one-of+ "xyz"))  ; {:ok? true}
(parse-lilac "w" (one-of+ "xyz"))  ; {:ok? false}
(parse-lilac "xy" (one-of+ "xyz")) ; {:ok? true, :rest ("y")}

反过来, 可以有排除的规则,

(parse-lilac "x" (other-than+ "abc"))  ; {:ok? true, :rest nil}
(parse-lilac "xy" (other-than+ "abc")) ; {:ok? true, :rest ("y")}
(parse-lilac "a" (other-than+ "abc"))  ; {:ok? false}

在此基础上, 增加一些逻辑, 表示判断的规则可以不存在,
当然允许不存在的话, 任何时候都可以退回到 true 的结果的,

(parse-lilac "x" (optional+ (is+ "x"))) ; {:ok? true, :rest nil}
(parse-lilac "" (optional+ (is+ "x"))) ; {:ok? true, :rest nil}
(parse-lilac "x" (optional+ (is+ "y"))) ; {:ok? true, :rest("x")}

也可以设定规则, 判断多个, 也就是大于 1 个(目前不能控制具体个数),

(parse-lilac "x" (many+ (is+ "x")))
(parse-lilac "xx" (many+ (is+ "x")))
(parse-lilac "xxx" (many+ (is+ "x")))
(parse-lilac "xxxy" (many+ (is+ "x")))

如果允许 0 个的情况, 就不是 many 了, 而是 some 的规则,

(parse-lilac "" (some+ (is+ "x")))
(parse-lilac "x" (some+ (is+ "x")))
(parse-lilac "xx" (some+ (is+ "x")))
(parse-lilac "xxy" (some+ (is+ "x")))
(parse-lilac "y" (some+ (is+ "x")))

相应的, or 的规则可以写出来,

(parse-lilac "x" (or+ [(is+ "x") (is+ "y")]))
(parse-lilac "y" (or+ [(is+ "x") (is+ "y")]))
(parse-lilac "z" (or+ [(is+ "x") (is+ "y")]))

而 combine 是用来顺序组合多个规则的,

(parse-lilac "xy" (combine+ [(is+ "x") (is+ "y")]))  ; {:ok? true, :rest nil}
(parse-lilac "xyz" (combine+ [(is+ "x") (is+ "y")])) ; {:ok? true, :rest ("z")}
(parse-lilac "xy" (combine+ [(is+ "y") (is+ "x")]))  ; {:ok? flase}

而 interleave 是表示两个规则, 然后相互间隔重复,
这种场景很多都是逗号间隔的表达式的处理当中用到,

(parse-lilac "xy" (interleave+ (is+ "x") (is+ "y")))
(parse-lilac "xyx" (interleave+ (is+ "x") (is+ "y")))
(parse-lilac "xyxy" (interleave+ (is+ "x") (is+ "y")))
(parse-lilac "yxy" (interleave+ (is+ "x") (is+ "y")))

另外当前的代码还提供了几个内置的规则, 用来判断字母, 数字, 中文的情况,

(parse-lilac "a" lilac-alphabet)
(parse-lilac "A" lilac-alphabet)
(parse-lilac "." lilac-alphabet) ; {:ok? false}

(parse-lilac "1" lilac-digit)
(parse-lilac "a" lilac-digit) ; {:ok? false}

(parse-lilac "汉" lilac-chinese-char)
(parse-lilac "E" lilac-chinese-char)  ; {:ok? false}
(parse-lilac "," lilac-chinese-char)  ; {:ok? false}
(parse-lilac "," lilac-chinese-char) ; {:ok? false}

具体某些特殊的字符的话, 暂时只能通过 unicode 范围来指定了.

(parse-lilac "a" (unicode-range+ 97 122))
(parse-lilac "z" (unicode-range+ 97 122))
(parse-lilac "A" (unicode-range+ 97 122))

有了这些规则, 就可以组合来模拟正则的功能了, 比如查找匹配项有多少,

(find-lilac "write cumulo and respo" (or+ [(is+ "cumulo") (is+ "respo")]))
; find 2
(find-lilac "write cumulo and phlox" (or+ [(is+ "cumulo") (is+ "respo")]))
; find 1
(find-lilac "write cumulo and phlox" (or+ [(is+ "cirru") (is+ "respo")]))
; find 0

或者直接进行字符串替换, 这就跟正则差不多了.

(replace-lilac "cumulo project" (or+ [(is+ "cumulo") (is+ "respo")]) (fn [x] "my"))
; "my project"
(replace-lilac "respo project" (or+ [(is+ "cumulo") (is+ "respo")]) (fn [x] "my"))
; "my project"
(replace-lilac "phlox project" (or+ [(is+ "cumulo") (is+ "respo")]) (fn [x] "my"))
; "phlox project"

可以看到, 这个写法就是组合出来的, 写起来比正则长, 但是可以定义变量, 做一些抽象.

简单的例子可能看不出这样做有什么用, 可能就是觉得搞得反而更长了, 而且性能更差.
我的项目当中有个简单的 JSON 解析的例子, 这个用正则就搞不定了吧...
直接搬运代码如下:

; 判断 true false 两种情况, 返回的是 boolean
(def boolean-parser
  (label+ "boolean" (or+ [(is+ "true") (is+ "false")] (fn [x] (if (= x "true") true false)))))

(def space-parser (label+ "space" (some+ (is+ " ") (fn [x] nil))))

; 组合一个包含空白和逗号的解析器, label 只是注释, 可以忽略
(def comma-parser
  (label+ "comma" (combine+ [space-parser (is+ ",") space-parser] (fn [x] nil))))

(def digits-parser (many+ (one-of+ "0123456789") (fn [xs] (string/join "" xs))))

; 为了简单, null 和 undefined 直接返回 nil 了
(def nil-parser (label+ "nil" (or+ [(is+ "null") (is+ "undefined")] (fn [x] nil))))

; number 的情况, 需要考虑前面可能有负号, 后面可能有小数点
; 这边偷懒没考虑科学记数法了...
(def number-parser
  (label+
   "number"
   (combine+
     ; 负号.. 可选的
    [(optional+ (is+ "-"))
     digits-parser
                ; 组合出来小数部分, 这也是可选的
     (optional+ (combine+ [(is+ ".") digits-parser] (fn [xs] (string/join "" xs))))]
    (fn [xs] (js/Number (string/join "" xs))))))

(def string-parser
  (label+
   "string"
   (combine+
     ; 字符串的解析, 引号开头引号结尾
    [(is+ "\"")
            ; 中间是非引号的字符串, 或者转义符号的情况
     (some+ (or+ [(other-than+ "\"\\") (is+ "\\\"") (is+ "\\\\") (is+ "\\n")]))
     (is+ "\"")]
    (fn [xs] (string/join "" (nth xs 1))))))

(defparser
 value-parser+
 ()
 identity
 (or+
  [number-parser string-parser nil-parser boolean-parser (array-parser+) (object-parser+)]))

(defparser
 object-parser+
 ()
 identity
 (combine+
  [(is+ "{")
   (optional+
     ; 对象就比较复杂了, 主要看 interleave 部分吧, 外边只是花括号的处理
    (interleave+
     (combine+
      [string-parser space-parser (is+ ":") space-parser (value-parser+)]
      (fn [xs] [(nth xs 0) (nth xs 4)]))
     comma-parser
     (fn [xs] (take-nth 2 xs))))
   (is+ "}")]
  (fn [xs] (into {} (nth xs 1)))))

(defparser
 array-parser+
 ()
 (fn [x] (vec (first (nth x 1))))
 (combine+
  [(is+ "[")
          ; 数组, 同样是 interleave 的情况
   (some+ (interleave+ (value-parser+) comma-parser (fn [xs] (take-nth 2 xs))))
   (is+ "]")]))

可以看到, 通过 lilac-parser 构造规则的当时, 比较容易就生成了一个 JSON Parser.
虽然支持的规则比较简单, 而且性能不大理想, 但是比起正则来说, 这个代码可读很多了.
相信可以作为一种思路, 用在很多文本处理的场景当中.
为了也许可以提供简化一些的版本, 在 JavaScript 直接使用, 代替正则.

查看原文

赞 2 收藏 0 评论 0

题叶 赞了文章 · 7月21日

Promise不是Callback

这一篇是在实际工程中遇到的一个难得的例子;反映在Node里两种编程范式的设计冲突。这种冲突具有普适性,但本文仅分析问题本质,不探讨更高层次的抽象。


我在写一个类似HTTP的资源协议,叫RP,Resource Protocol,和HTTP不同的地方,RP是构建在一个中立的传输层上的;这个传输层里最小的数据单元,message,是一个JSON对象。

协议内置支持multiplexing,即一个传输层连接可以同时维护多个RP请求应答过程。

考虑客户端request类设计,类似Node内置的HTTP Client,或流行的npm包,如requestsuperagent

可以采用EventEmitter方式emit errorresponses事件,也可以采用Node Callback的形式,需要使用者提供接口形式为(err, res) => {}的callback函数。

随着async/await的流行,request类也可以提供一个.then接口,用如下方式实现(实际上superagent就是这么实现的):

class Request extends Duplex {
    constructor () {
        super()
        ...
        this.promise = new Promise((resolve, reject) => {
            this.resolve = resolve
            this.reject = reject
        })
    }
    
    then (...args) {
        return this.promise.then(...args)
    }
}

RP的实际设计,形式和大家熟悉的HTTP Client有一点小区别,response对象本身不是stream,而是把stream做为一个property提供。换句话说,callback函数形式为:

(err, { data, chunk, stream }) => {}

如果请求返回的不是stream,则data或者chunk有值;如果返回的是stream,则仅stream有值,且为stream.Readable类型。

这个形式上的区别和本文要讨论的问题无关。


RP底层从传输层取二进制数据,解析出message,然后emit给上层;它采用了一个简单方式,循环解析收到的data chunk,直到没有完整的message为止。

这意味着可以在一个tick里分发多个消息。request对象也必须能够在一个tick里处理多个来自服务端的消息。

我们具体要讨论的情况是服务器连续发了这样两条消息:

  1. status 200 with stream
  2. abort

第一条意思是后面还有message stream,第二条abort指server发生意外无法继续发送了。

request对象收到第一条消息时,它创建response对象,包含stream对象:

this.res = { stream: new stream.Readabe({...}) }
// this.emit('response', this.res)
// this.callback(null, this.res)
this.resolve(this.res)

象注释中emit或trigger使用者提供的callback,都没有问题;但如果调用resolve,注意,Promise是保证异步的,这意味着使用者通过then提供的onFulfilled,不会在当前tick被调用。

接下来第二条消息,abort,在同一个tick被处理;但这个时候,因为使用者还没来得及挂载上任何listener,包括error handler,如果设计上要求这个stream emit error——很合理的设计要求——此时,按照Node的约定,error没有handler,整个程序crash了。


这个问题的dirty fix有很多种办法。

首先request.handleMessage方法,如果无法同步完成对message的处理,而message的处理顺序又需要保证,它应该buffer message,这是node里最常见的一种synchronize方式,代表性的实现就是stream.Writable

但这里有一个困难,this.resolve这个函数没有callback提供,必须预先知道运行环境的Promise实现方式;在node里是nextTick,所以在this.resolve之后nextTick一下,同时buffer其它后续消息的处理,可以让使用者在onFulfilled函数中给stream挂载上handler。


这里可以看出,callback和emitter实际上是同步的。

当调用callback或者listener时,request和使用者做了一个约定,你必须在这个函数内做什么(在对象上挂载所有的listener),然后我继续做什么(处理下一个消息,emit data或者error);这相当于是interface protocol对顺序的约定。

我们可以称之为synchronous sequential composition,是程序语义意义上的。

对应的asynchronous版本呢?

如果我们不去假设运行环境的Promise的实现呢?它应该和同步版本的语义一样对吧。


再回头看看问题,假如stream emit error不会导致系统crash,使用者在onFulfilled拿到{ stream }这个对象时,它看到了什么?一个已经发生错误后结束了的stream。

这个可能使用上会难过一点,需要判断一下,但还感觉不出是多大的问题。

再进一步,如果是另一种情况呢?Server在一个chunk里发来了3个消息;

  1. status 200 with stream
  2. data
  3. abort

这个时候使用者看到的还是一个errored stream,data去哪里了呢?你还能说asynchronous sequential composition的语义和synchronous的一致么?不能了对吧,同步的版本处理了data,很可能对结果产生影响。

在理想的情况下,sequential composition,无论是synchronous的,还是asynchronous的,语义(执行结果)应该一致。

那么来看看如何做到一个与Promise A+的实现无关的做法,保证异步和同步行为一致。

如果你愿意用『通讯』理解计算,这个问题的答案很容易思考出来:假想这个异步的handler位于半人马座阿尔法星上,那我们唯一能做的事情是老老实实按照事件发生的顺序,发送给它,不能打乱顺序,就像我们收到他们时一样。

但是当我们把进来的message,翻译实现成stream时,没能保证这个order,包括:

  1. abort消息抢先/乱序
  2. data消息丢失了

这是问题的root cause,当我们异步处理一个消息序列时,前面写的实现break了顺序和内容的完整性。


在数学思维上,我们说Promise增加了一个callback/EventEmitter不具备的属性,deferred evaluation,是一个编程中罕见的temporal属性;当然这不奇怪,因为这就是Promise的目的。

同时Promise -> Value还有一个属性是它可以被不同的使用者访问多次,保持了Value的属性。

这也不奇怪。

只是Stream作为一种体积上可以为无穷大的值,在实践中不可能去cache所有的值,把它整体当成一个值处理,所以这个可以被无限提取的『值』属性就消失了。


但是这不意味着stream作为一个对象,它的行为,不能延迟等到它被构造且使用后才开始处理消息。

一种方式是写一个stream有这种能力的;stream.Readable有一个flow属性,必须通过readable.resume开始,这是一个触发方式;另一个方式是有点tricky,可以截获response.stream的getter,在它第一次被访问时触发异步处理buffered message。

这样的做法是不需要依赖Promise A+的实现的;但不是百分百asynchronous sequential composition,因为stream的handler肯定是synchronous的。

完全的asynchronous可以参照Dart的使用await消费stream的方式。

它的逻辑可以这样理解:把所有Event,无论哪里来的,包括error,都写到一个流里去,用await消费这个流;但实际上在await返回的时候仍然面对一个状态机,好处是

  1. throw给力;
  2. 流程等待方便,即处理流输出的对象时还可以有await语句,在取下一个流输出的对象之前,相当于一种blocking;但这种blocking需要慎重,它是反并发的;

总结:

Node的Callback和EventEmitter在组合时handler/listener是同步的;Promise则反过来保证每个handler/listener都是异步组合,这是两者的根本区别。

在顺序组合函数(或者进程代数意义上的进程)上,同步组合是紧耦合的;它体现在一旦功能上出现什么原因,需要把一个同步逻辑修改成异步时,都要大动干戈,比如本来是读取内存,后来变成了读取文件。

如果程序天生写成异步组合,类似变化就不会对实现逻辑产生很大影响;但是细粒度的异步组合有巨大的性能损失,这和现代处理器和编译器的设计与实现有关。

真正理想的情况应该是开发者只表达“顺序”,并不表达它是同步还是异步实现;就像前面看到的,实际上同步的实现都有可以对应的异步实现,差别只是执行效率和内存使用(buffer有更多的内存开销,同步处理实际上更多是『阅后即焚』);

但我们使用的imperative langugage不是如此,它在强制你表达顺序;而另外一类号称未来其实狗屎的语言,在反过来强制你不得表达顺序。

都是神经病。学术界就不会真正理解产业界的实际问题。

查看原文

赞 8 收藏 4 评论 0

题叶 发布了文章 · 7月21日

ClojureScript core.async 丰富的语义和示例

这篇笔记主要是基于文档展开一下 core.async 在 ClojureScript 当中的基本用法.
具体的内容可以看原文章, 已经比较详细了, 很多在 API 文档的 demo 当中.
关于基础知识跟 cljs 跟 clj 的区别, 这篇文章就不涉及了.

之前用到 core.async , 发现自己中间很多理解缺失了, 趁有时间赶紧看一下.
从 API 文档可以看到 core.async 的函数语义是比较丰富的, 几十个函数,
我顺着看了一圈, 整理下来大致是几个功能, 大致分成几块.

  • 多对一

    • 类似 Promise.all
    • 多个 channel 的数据进行 map
    • 通过 merge 合并多个 channel 到一个
    • 通过 mix 控制多个 channel 具体合并/解开的情况
  • 多选一

    • alts! 的多选一, 对应 Promise.race
    • alt! 的语法套路
  • 一拆二/过滤

    • split 直接拆成两个
    • 用 pipeline 搭配 filter 进行过滤
    • 用 transducer 写法进行过滤
  • 一对多

    • 通过 mult 发送给多个接收端

本来我的触发点是想看 core.async 是都能对应到 Promise 常用的功能,
这样看下来, core.async 功能是过于丰富了, 反而有些用不上.
由于我对 Go 熟悉度有限, 不好跟 Go Channel 做对比了.

后面逐个看一下示例. 为了方便演示, 我增加了两个辅助函数,

  • fake-task-chan, 生成一个随机定时任务, 返回一个 channel
  • display-all, 打印一个 channel 所有返回的非 nil 数据, nil 表示结束.

多对一

类似 Promise.all

(defn demo-all []
  (go
         ; 首先 tasks 得到向量, 内部函数多个 channel
   (let [tasks (->> (range 10)
                    (map (fn [x] (fake-task-chan (str "rand task " x) 10 x))))]
     (println "result"
        ; loop 函数逐个取 tasks 的值, 从头取, 一次次 recur, 直到取完, 结束
        (loop [acc [], xs tasks]
          (if (empty? xs)
              acc
                               ; <! 表示从 channel 取数据, 在 go block 内阻塞逻辑
              (recur (conj acc (<! (first xs)))
                     (rest xs))))))))

由于任务在 loop 之前已经开始了, 类似 Promise.all 的效果.
一个个等待结果, 最终就是全部的值, 耗时就是最长的那个等待的时间.

=>> node target/server.js
rand task 0 will take 0 secs
rand task 1 will take 1 secs
rand task 2 will take 2 secs
rand task 3 will take 3 secs
rand task 4 will take 9 secs
rand task 5 will take 6 secs
rand task 6 will take 7 secs
rand task 7 will take 1 secs
rand task 8 will take 2 secs
rand task 9 will take 9 secs
rand task 0 finished
rand task 1 finished
rand task 7 finished
rand task 2 finished
rand task 8 finished
rand task 3 finished
rand task 5 finished
rand task 6 finished
rand task 4 finished
rand task 9 finished
result [0 1 2 3 4 5 6 7 8 9]

可以看到最终以数组形式返回了每个 channel 返回的数据了.

多个 channel 的数据进行 map

我其实不大清楚这个 map 用在什么样的场景, 就是取两个 channel 计算得到新的数字.

(defn demo-map []
  (let [<c1 (to-chan! (range 10))
        <c2 (to-chan! (range 100 120))
        <c3 (async/map + [<c1 <c2])]
    (display-all <c3)))

所以就是 0 + 1001 + 101... 得到 10 个数据

=>> node target/server.js
100
102
104
106
108
110
112
114
116
118
nil

总体上还是多个 channel 合并成一个了.

通过 merge 合并多个 channel 到一个

merge 就是把多个 channel 的数据合并到一个, 字面意义的意思.
从得到的新的 channel, 可以获取到原来 channel 的数据.

(defn demo-merge []
  (let [<c1 (chan),
        <c2 (chan),
        <c3 (async/merge [<c1 <c2])]
    (go (>! <c1 "a") (>! <c2 "b"))
    (display-all <c3)))

所以从 c3 就能拿到写到原来的两个 channel 的数据了,

=>> node target/server.js
a
b

通过 mix 控制多个 channel 具体合并/解开的情况

mix 跟 merge 很相似, 区别是中间多了一个控制层, 定义成 mix-out,
通过 admixunmix 两个函数可以调整 mix-out 上的关系,
这个例子当中

(defn demo-mix []
  (let [<c0 (chan)
        <c1 (async/to-chan! (range 40))
        <c2 (async/to-chan! (range 100 140))
        mix-out (async/mix <c0)]
    ; mix 过来两个 channel
    (async/admix mix-out <c1)
    (async/admix mix-out <c2)
    (go
               ; 先取 20 个数据打印
     (doseq [x (range 20)] (println "loop1" (<! <c0)))
     (println "removing c2")
     ; 去掉那个数字特别大的 channel
     (async/unmix mix-out <c2)
               ; 再取 20 个数据打印
     (doseq [x (range 20)] (println "loop2" (<! <c0))))))

得到结果,

=>> node target/server.js
loop1 0
loop1 100
loop1 1
loop1 101
loop1 2
loop1 102
loop1 3
loop1 103
loop1 104
loop1 4
loop1 105
loop1 5
loop1 106
loop1 6
loop1 107
loop1 108
loop1 109
loop1 110
loop1 7
loop1 8
removing c2
loop2 111
loop2 9
loop2 10
loop2 11
loop2 12
loop2 13
loop2 14
loop2 15
loop2 16
loop2 17
loop2 18
loop2 19
loop2 20
loop2 21
loop2 22
loop2 23
loop2 24
loop2 25
loop2 26
loop2 27

可以看到刚开始的时候, 从返回的 channel 可以获取到两个来源 channel 的数据,
进行一次 unmix 之后, 大数的来源不见了, 后面基本上是小的数字.

这个顺序看上去是有一些随机性的, 甚至 unmix 还有一次大数的打印, 后面稳定了.
注意 mix-out 只是用于控制, 获取数据在代码里还是要通过 c0 获取的.

多选一

alts! 的多选一, 对应 Promise.race

这个比较清晰的

(defn demo-alts []
  (go
   (let [<search (fake-task-chan "searching" 20 "searched x")
         <cache (fake-task-chan "looking cache" 15 "cached y")
         <wait (fake-task-chan "timeout" 15 nil)
                       ; 数组里边三个可选的 channel
         [v ch] (alts! [<cache <search <wait])]
     (if (= ch <wait ) (println "final: timeout")
                       (println "get result:" v)))))

就是随机的时间, 取返回最快的结果. 我多跑几次

=>> node target/server.js
searching will take 3 secs
looking cache will take 14 secs
timeout will take 9 secs
searching finished
get result: searched x
^C
=>> node target/server.js
searching will take 10 secs
looking cache will take 1 secs
timeout will take 4 secs
looking cache finished
get result: cached y
timeout finished
searching finished
^C
=>> node target/server.js
searching will take 19 secs
looking cache will take 4 secs
timeout will take 1 secs
timeout finished
final: timeout
looking cache finished
^C
=>> node target/server.js
searching will take 0 secs
looking cache will take 6 secs
timeout will take 1 secs
searching finished
get result: searched x
timeout finished
looking cache finished
^C

可以看到打印的结果都是最短时间结束的任务对应的返回值.
timeout 是这种情况当中比较常用的一个定时器, 控制超时.

alt! 的语法套路

alt! 跟 alts! 就是类似了, 主要是语法比较丰富一点,

(defn demo-alt-syntax []
  (let [<search1 (fake-task-chan "search1" 10 "search1 found x1")
        <search2 (fake-task-chan "search2" 10 "search2 found x2")
        <log (chan)
        <wait (fake-task-chan "timeout" 10 nil)]
    (go
     (loop []
       (let [t (rand-int 10)]
         (println "read log waits" t)
         (<! (timeout (* 1000 t)))
         (println "got log" (<! <log))
         (recur))))
    (go
     (println "result"
       (async/alt!
         ; 匹配单个 channel 的写法
         <wait :timeout
         ; 这个是往 channel 发送消息的写法, 发送也是等待对方读取, 也受时间影响
         ;  这个两层数组是挺邪乎的写法...
         [[<log :message]] :sent-log
         ; 这个匹配向量包裹的多个 channel, 后面通过 ch 可以区分命中的 channel
         [<search1 <search2] ([v ch] (do (println "got" v "from" ch)
                                         :hit-search)))))))

直接多跑几次了, 效果跟上边一个差不多的,

=>> node target/server.js
search1 will take 3 secs
search2 will take 7 secs
timeout will take 3 secs
read log waits 8
search1 finished
timeout finished
got search1 found x1 from #object[cljs.core.async.impl.channels.ManyToManyChannel]
result :hit-search
search2 finished
^C
=>> node target/server.js
search1 will take 2 secs
search2 will take 0 secs
timeout will take 4 secs
read log waits 2
search2 finished
got search2 found x2 from #object[cljs.core.async.impl.channels.ManyToManyChannel]
result :hit-search
search1 finished
timeout finished
^C
=>> node target/server.js
search1 will take 9 secs
search2 will take 0 secs
timeout will take 9 secs
read log waits 6
search2 finished
got search2 found x2 from #object[cljs.core.async.impl.channels.ManyToManyChannel]
result :hit-search
search1 finished
timeout finished
^C
=>> node target/server.js
search1 will take 2 secs
search2 will take 2 secs
timeout will take 2 secs
read log waits 9
search1 finished
search2 finished
timeout finished
got search1 found x1 from #object[cljs.core.async.impl.channels.ManyToManyChannel]
result :hit-search
^C
=>> node target/server.js
search1 will take 6 secs
search2 will take 3 secs
timeout will take 1 secs
read log waits 6
timeout finished
result :timeout
search2 finished

一拆二/过滤

split 直接拆成两个

看文档好像就是直接这样拆成两个的, 对应 true/false,

(defn demo-split []
  (let [<c0 (to-chan! (range 20))]
    (let [[<c1 <c2] (async/split odd? <c0)]
      (go (display-all <c2 "from c2"))
      (go (display-all <c1 "from c1")))))

然后得到数据就是分别从不同的 channel 才能得到了, 奇书和偶数,

=>> node target/server.js
from c2 0
from c1 1
from c1 3
from c2 2
from c2 4
from c2 6
from c1 5
from c1 7
from c1 9
from c2 8
from c2 10
from c2 12
from c1 11
from c1 13
from c1 15
from c2 14
from c2 16
from c2 18
from c1 17
from c1 19
from c1 nil
from c2 nil

用 pipeline 搭配 filter 进行过滤

这个 pipeline 就是中间插入一个函数, 例子里是 filter, 直接进行过滤.

(defn demo-pipeline-filter []
  (let [<c1 (to-chan! (range 20)),
        <c2 (chan)]
    (async/pipeline 1 <c2 (filter even?) <c1)
    (display-all <c2)))

效果就是从 c2 取数据时, 只剩下偶数的值了,

=>> node target/server.js
0
2
4
6
8
10
12
14
16
18
nil

用 transducer 写法进行过滤

transducer 比较高级一点, 用到高阶函数跟比较复杂的抽象,
但是简单的功能写出来, 主要发挥作用的函数那个 (filter even?),
柯理化的用法, 返回函数, 然后被 comp 拿去组合,

(defn demo-transduce-filter []
  (let [<c1 (to-chan! (range 20)),
        <c2 (chan 1 (comp (filter even?)))]
    (async/pipe <c1 <c2)
    (display-all <c2)))

得到的结果跟上边是一样的, 都是过滤出偶数,

=>> node target/server.js
0
2
4
6
8
10
12
14
16
18
nil

一对多

通过 mult 发送给多个接收端

就是把一个数据变成多份, 供多个 channel 过来取数据,

(defn demo-mult []
  (let [<c0 (async/to-chan! (range 10)),
        <c1 (chan),
        <c2 (chan),
        ; mult-in 也是一个控制, 而不是一个 channel
        mult-in (async/mult <c0)]
    (async/tap mult-in <c1)
    (async/tap mult-in <c2)
    (display-all <c1 "from c1")
    (comment "need to take from c2, otherwise c0 is blocked")
    (display-all <c2 "from c2")))

可以看到运行以后就是 c1 c2 分别拿到一份一样的数据了,

=>> node target/server.js
from c1 0
from c2 0
from c1 1
from c1 2
from c2 1
from c2 2
from c1 3
from c1 4
from c2 3
from c2 4
from c1 5
from c1 6
from c2 5
from c2 6
from c1 7
from c1 8
from c2 7
from c2 8
from c1 9
from c1 nil
from c2 9
from c2 nil

大概的场景应该就是一个数据发布到多个 channel 去吧.
不过这个跟监听还有点不一样, 监听广播时发送者是非阻塞的, 这边是 channel 是阻塞的.

结尾

代码后续会同步到 github 去 https://github.com/worktools/... .

这边主要还是 API 的用法, 业务场景当中使用 core.async 会复杂一些,
比如 debounce 的逻辑用 timeout 搭配 loop 处理就比较顺,
具体代码参考, https://zhuanlan.zhihu.com/p/...
但一般都是会搅尾递归在里边, 进行逻辑控制和状态的传递.
网上别人的例子加上业务逻辑还会复杂很多很多...

但总的说, Clojure 提供的 API, 还有抽象能力, 应该是可以用来应对很多场景的.

查看原文

赞 3 收藏 2 评论 0

题叶 发布了文章 · 6月30日

关于 HCL 颜色格式的一些笔记

关于 HCL 颜色的介绍, 之前在有个文章里看到过:
产品配色2.0:使用HCL 色彩空间替代HSL 生成配色- 二三事
之前关于 HSL 的介绍, 说的是 HSL 比 RGB 更符合人们的视觉,
https://cdc.tencent.com/2011/...
因为 HSL 的几个数值是色相/饱和度/亮度, 容易增减来调色,
但是按照开头的 HCL 文章介绍, HSL 颜色也存在问题,

颜色的区别

为了清楚展示问题, 我做了一个工具来展示色环 http://tools.mvc-works.org/co...
其中 HSL 模式, S 取, 100, L 取 61, 效果看起来是这样,
可以看到黄色的区域非常亮, 蓝色就显得比较暗.

download.png

当然这个颜色也是合理的, 蓝色本来就是不耀眼的一种颜色.
HSL 跟直接跟 RGB 颜色对应的, 对应到蓝色的像素的亮度.
用 HSL 的话, 生成颜色也算比较方便, 改变其中一个参数就好.

不过, HCL 认为, HSL 的亮度 L 其实有问题,
当 L 颜色一致的时候, 颜色的亮度应该是基本一致的,
也就是蓝色对应的统一个 L, 在相对的区域, 颜色的亮度应该大体一致,
用 HCL 渲染出来 L=61 就是这样子, 当然黄色已经看不到了,

download (1).png

如果要从 HCL 当中显示黄色, 我调了一下, L 需要调整到 91,
这个时候蓝色的区域也被提升得很亮, 是接近白色的浅蓝色了,

download (2).png

如果是 HSL 当中 L=91, 看一下效果, 这些颜色已经都接近白色了,

download (3).png

这也包含了 HCL 的一个区别, 就是 L=100 对应的并不是白色,
在 HCL 当中 C=0, L=100 才能调出白色.

代码计算

当前 demo 实现参考 https://github.com/worktools/... .
颜色基色使用的是 https://www.npmjs.com/package...

d3.hcl(h, c, l_[, opacity])

可以比较快的创建一个 HSL 颜色, 然后再转换到其他的颜色格式,

color.formatHex()
color.formatRgb()
color.formatHsl()

另一个模块还有 chroma-js, 也可以进行转换, 但是我这边没有深入用.
初步感觉不如 d3-color 方便用.

D3 也是使用 HCL 比较多的场景, 图表自动生成的颜色用 HCL 更好.
我这边遇到的场景是生成的一个折线图的颜色, 发现蓝紫色亮度低很难看清.
于是我想到是 HCL 颜色指出的那个问题, 所以把相关的 API 扒出来试了一遍.
从效果看, 很明显 HCL 亮度控制得比较好, 只是在色彩明艳程度有区别.
因为亮黄色跟深蓝色亮度明显不一致, 所以基本不会出现在同一个图上了,
这样的话, 颜色的对比没有原来鲜艳强烈了..

原理

扒了一些资料, 大部分细节没有弄明白, 具体的换算也没弄清楚.
大致搜集了一些资料, 需要的话详查...

https://en.wikipedia.org/wiki...

http://hclwizard.org/why-hcl/

原始论文 https://pdfs.semanticscholar....

一个系统介绍了颜色的博客 https://www.jianshu.com/u/9d9...

查看原文

赞 2 收藏 1 评论 0

题叶 发布了文章 · 4月6日

对于简聊 React 的一些回忆和反思(初稿)

看到钉钉的功能越来越多了, 前段时间突然想起来以前简聊的事情来.
当前公司跟钉钉的一些风声, 具体也不清楚, 到很多年后才听到了收购的事情.
Slack 具体的玩法我并不清楚, 但是钉钉当前延伸出来的功能给我一些感触,
当年简聊在功能的扩展来说缺了太多围绕聊天的扩展创新, 玩法也不温不火,
当然如果当时有条件一步一步有条理地往外扩展, 也说不准后面是不是会有机会.
我当时也基本上一心在处理 React 的事情, 极少对总体的产品形态提出想法.

单纯从前端代码来说, 我们当时也算很早尝试 React 并且稳定下来了.
经过这些年, React 改变也不少, class 组件到今天用的人比较少了,
而且随着我到不同的公司遇到不同的场景, 代码的侧重也改变了不少.
再当时来说有些东西看着是迷雾, 还要不断前行探索才能知晓,
现在回头看的话, 是比起从前清晰了很多, 那些说是弯路说是妥协的东西.

CoffeeScript, ClojureScript, TypeScript

CoffeeScript 在当年算是 alt js 当中最成熟的一门语言.
就功能来说, 它仅仅是让人更容易些 js 的语法糖而已.
跟 ClojureScript 相比, CoffeeScript 没有原生的不可变数据支持,
跟 TypeScript 相比, CoffeeScript 没有类型系统加持.
上面说的这两个 ReasonML 都有, 可惜到现在 ReasonML 给人感觉都还不够成熟.

就功能来说, CoffeeScript 确实是残缺的,
当时虽然简历了代码规范, 但是很大程度还是依靠人自觉把格式统一起来.
而现在, 无论 ClojureScript 还是 TypeScript 都是通过自动化格式工具写的,
Prettier 的使用基本追平了的 CoffeeScript 带来的编写效率的提升,
而且就团队代码习惯来说, Prettier 比起 CoffeeScript 当然是好多了.
不可变数据的残缺, 导致了一系列的奇怪问题, 后面再说.
没有类型系统确实也不如 TypeScript 适合多人团队.

不过当年 ClojureScript 跟 TypeScript 比较还是很少出现的,
我印象里公司倒是有那个人在研究 TypeScript 这些了,
而 ClojureScript 在当时工具链也还有各种问题, 即便今天也有一些.
不过即便是今天, 由于招人的原因还有工具链特殊, 我还是不觉得能在多人开发当中用起来.
ClojureScript 更多的, 还是让我站在 FP 语言的角度审视 js 生态缺失的功能.

Immutable 和 Immer

前面说的 CoffeeScript 或者 js 没有原生不可变数据的问题,
按照 React 的设计, 不可避免需要用到不可变数据, 否则优化起来会很绕.
现在来说, 有了 Hooks API, useState 把数据一份份拆开了,
这样每个数据更新的时候, 算是通过替换达到了不可变数据的效果,
但是也存在过于分散且触发多次更新的问题. 还是有时候需要不可变的对象来处理.

当时用 Immutable.js 的问题主要就是学习成本,
我最开始从了解这个东西到熟悉了敢用在项目当中也花了不短的时间.
除了写法比较绕以外, 对于前端来说这也是比较陌生的一种用法.
后来即便我用了, 我印象里同事还是有不少的抱怨的, 比较维护着累.
这东西并不是不能掌握, 但是当在 js 当中写起来就是挺烦的.
而且对于第三方代码来说, 只有 JSON 对象时它们接受的数据格式.
那么 Immutable 数据就要在我们代码当中转来转去, 维护就很累.

我后面用 ClojureScript 就不是这样的. Clojure 默认用不可变数据.
这样我在开发当中几乎无时无刻不是直接用不可变数据在编写逻辑,
就是说没有多少需要转换的场景, 脑子里对数据的理解统一而且清晰.
Lodash 当中 updateInsetIn 就是 Clojure 中很平常的用法.
这些 API 在 Immutable 当中更是比比皆是, 只是说 Clojure 做到了语言核心中.

现在我们用的是 Immer, 语法比较贴近 JavaScript 原生的操作.
就推广使用来说, 得益于语法简单, 而且加上 Hooks API 封装, 容易多了.
不过从 review 的结果来看, 还是偶尔会出现遗漏.
Immer 毕竟是用了 freeze 强行设定数据不可变, 但看着输出跟 js 又没区别.
就没法保证在场景当中串来串去不会漏掉. 当然也还好比较少.

不可变数据这个事情, 四五年了, 萦绕在 React 社区还是没有消散掉,
我觉得这个作为长期的一个方向, 也不会很容易就有完美的结果了.
如果说期待, 我希望 ReasonML 到时候能把这事情统一掉.
因为 js 总是要兼容老代码的, 不管怎样引入不可变数据, 心理负担总会在.

Less 和 emotion

简聊用的 CSS 预编译方案是 LESS, 当时 teambition 统一的习惯.
总的来说我对这种层层重置的 CSS 规则也没留下多好的印象.
当初 Vjeux 那个 CSS in JS 的演讲发布的时候我是深有感触的,
用了两三年的预编译, 大部分的局限性基本上也碰到了,
我是很期待直接用上 CSS in JS 的方案, 做细粒度的包含代码逻辑的控制.

就当时来说, 我觉得方案是不够成熟的, styled-components 也觉得走得太怪.
在我自己 cljs 的方案里边, 我后来用的是 inline styles, 局部场景够用,
而 inline styles 最主要的问题是浏览器前缀和伪类的控制, 没法用在产品当中.

后来在朋友当中了解到 emotion 的方案相对成熟, 我就尝试了一下,
我最后还是没有用 styled-components 那样的写法,
对于 emotion 我只是当做定义 className 写法来用, 倒不是新版本官网推荐的用法.
如果参考官网, 反而我们的写法显得并不规范, 也不好配合官方 Babel 的优化.
只能说堪堪到了一个解决掉我认为痛点问题的状态,

let styleA = css`
  color: red;
`

let styleB = css`
  background-color: blue;
`

<div className={cx(styleA, styleB)}>red</div>

但是这也却是帮我规避了很多的问题, 也因为是变量所以很好用代码进行控制.

问题主要在团队当中, 以往 LESS 的写法是大家都熟悉的, 有一套习惯.
加上 emotion 官网推荐的是 styled-components, 就造成了不统一.
而我的话没有多少动力去推动这方面的改变了, 这些涉及到不止一个改变的地方.

Mixin 和 Hooks

简聊遗留的代码中组件复用的代码使用的是 Mixin.
实际上能抽取出来的复用的方法并不多, 而且维护性并不算好.
在 Class 上动态注入的方法, 维护当中基本靠约定, 不容易排查.
Mixin 的用法后来对着 createClass 被废弃也跟着废弃了, 改为单继承.

中间用高阶组件进行逻辑抽象的事情大家都经历过了,
现在的结果也都知道了, 随着 Hooks 出来, HOC 基本没人说了.
当初 HOC 我们也试着写过, 我还是参考着同事的代码写的,
但我真心觉得 decorator 的代码很容易写出问题来, 而且我很容易漏掉各种东西.
就逻辑复用来说, HOC 主要是类库作者用, 业务开发几乎不会去乱搞.

现在有了 Hooks 再去审视当初的 Mixin, 就觉得很原始.
Mixin 几乎只是粗暴地讲方法一个个剥离到单个的文件, 事情只是做了一半.
饭馆 Hooks 封装的逻辑较为完整, 复用的场景也多, 也认为较为可靠.
就我们现在来说已经大量使用 Hooks 用在业务抽象当中了,
所以 Hooks 是实实在在对于产品业务有很多帮助的, 而不限于类库开发者.
回顾简聊的代码, Mixin 也算抽了二十多个吧, 这数量是不如 Hooks 的.

Props 和类型

简聊代码当中主要用 PropTypes 进行的组件参数的管理.
后来这些东西渐渐废弃掉了, 有点不知不觉的.
现在应该大部分人都使用的 TypeScript 声明组件的类型了吧,
有了类型的辅助, 即便没用动态的参数校验, 也很难出现那方面的错误.
就这一点来说 TypeScript 为前端带来了不小的改变.

actions-recorder 和 respo-reel

action-recorder 我在前面有留文章详细讲了, 是我改出来的名字.
原型的话, 是 Elm 的 hot code swapping 吧, 还有 Redux 的调试工具.
当时具体时间我记不清了, Elm 那些 Demo 大家应该都看过, Redux 也有些风声,
加上当时社区有不少人在追这方面的工具链, 我也就自己做了一些尝试.
actions-recorder 最初因为实现的问题, 在内部试用性能还挺差, 不过还好马上修复了.
actions-recorder 是为了简聊定制的类库, 基于 Immutable.js , 算是耦合了.
后来 Redux DevTools 大概做了更完整的版本, 我不清楚用的人有多少.

actions-recorder 的核心原理是所有的 actions 都存储下来,
后续可以通过切换 actions 和 updater 重新计算, 回放整个应用的状态.
这同时也推导出一个要求, 就是 didMount 等等操作时, 不可以发出 actions.
这个要求当时对我造成了不小的影响, 毕竟在 didMount 时 dispatch actions 挺常见的.
为了迎合这一套方案, 我花了不小的心思对相关逻辑进行了不小的改造.
特别大的一块就是切换也没请求数据的行为, 都跟 didMount 脱离了.

actions-recorder 这套重构当时也是也没有太多的难点, 逐渐就完成了.
从效果来说, 我认为还是不错的, 请求从 didMount 分离, 就是从 render 分离,
数据在路由切换前就开始请求, 时间上提早了一些, 界面上也避免了多个分离的 loading.
但是从坏的一面来说, 这种约束对于后续的开发维护来说是不友好的.
我印象当中我同事后面补代码的时候比较容易会破坏这个规则,
而且有了这些限制, 设计逻辑也累了许多. 谁不喜欢在 didMount 直接请求数据啊.

站在 actions-recorder 的角度, 回放 actions 好处比较明显,
当时有几个不好排查的 bug, 在 actions-recorder 的工具当中很容易定位,
因为每个 action 前后的数据状态都在, diff 可以直接看, 很明确.
但是一旦 didMount 时会发送 actions, 就导致回溯时总会有重复的 actions 发出,
这些 actions 也不好追踪, 那么调试的方便就被破坏掉了.
这些好处对我而言有用, 但是对团队来说却未必真的好, 现在看看确实问题太多.

后面我用 ClojureScript 写的代码当中, 对于 Respo 的小应用, 时间起来比较轻松.
由于 Respo 我在设计实现时基本上杜绝了 didMount 发 actions 的可能性,
所以天然就的是满足前面的约束, 而我用 respo-reel 类库很容易做到这一点.
另外 respo-reel 跟 cljs 纯函数的特性配合, 更新 updater 的效果也比较好.
某种程度说这个就是另一套独立的技术路线了.

就目前公司中而言, 我完全放弃了 actions-recorder 的方案(也没有用 Redux 那套).
当存在大量的伴随 didMount 进行局部的 actions dispatching 的时候,
actions-recorder 的方案就显得没有多少意义, 特别是还跟 Hooks 相关联.
actions-recorder 也要求数据尽量存储在全局, 这一点在当前的场景也不合适.
特别是个人开发大量的子页面, 各自有各自的数据, 跟简聊的场景就很不一样了.
虽然我最初有考虑复用部分 actions-recorder 的思路过来, 但仔细想总没有多少好处,
只能说是场景不同, 加上那些前车之鉴吧.

脱离 Elm 的全局状态的方案的话, actions 对我来说就没有太大的意义了.
如果是 ReasonML 那样通过类型能定义 action 方便做 switch 的话倒还好,
在 TypeScript 中用 Action 效果并不多, 而且还多一层抽象.
反而不如直接用方法去操作那少有的全局状态来得好维护了.

全局数据的使用

简聊的场景比较特殊一些, group 和 messages 是全局的数据, user 也是.
一方面 WebSockets 会推送新数据过来, 这些默认就是全局处理的, 而不是局部,
另一方面聊天应用就是围绕消息和用户展开的, 消息全局存储也有意义.

目前公司的场景是各种页面表单图表基本上是各自从服务器请求数据,
虽然形态上是单页面应用, 实际上子页面之间或者全局复用的数据可以说少得可怜.
这样大量的状态也就是在各个组件自己去做维护了, 并且状态也非常多.
大相径庭的场景. 当然状态很多对于调试来说也不大友好. 可现状就是这样.

router-view 和 ruled-router

由于前面说的 actions-recorder 的限制, 就要去所有全局状态都要在 store 统一维护,
比较重要的一块状态, 就是路由状态, 当时我也走到了 redux-router 的前面.
我设计了 router-view 模块, 改变了路由在 React 中的角色, 使用全局数据控制.
这是个定制化的方案, 也没必要展开说了.
另外这个 router-view 跟后来 Vue 说的 router-view 还不一样, 具体也没看.

不过有一点是后面影响我比较深的, 就是我认为 react-router 界面和路由耦合很有问题,
router-view 是定义 JSON 的路由规则对路径进行解析, 然后交给页面渲染,
这根 react-router 通过 Context 层层处理的方式完全不一样.
router-view 应该说更接近于后端解析和处理路由的方式, 先解析, 再当做数据处理.
解析完成以后, 处理数据对于 React 来说是非常清晰也很好排查问题的.
这个在 react-router 那样的耦合严重的方案当中就显得分散而且琐碎了.

这个思路导致后面为了解决公司面对的嵌套层级过深难以维护的问题时, 我直接选择了改换路由.
ruled-router是从 router-view 延伸的对于嵌套路由做更深层适配的方案.
后面发现配合 TypeScript 的类型还能做很多的定制, 就继续深入做了代码生成.
关于细节, 前面发文章讲过, 不再展开了.

但这边也还有个问题, 就是定制过深之后, 整个方案完全是自成体系了,
这个对于招聘进来的新人来说, 首先有一个学习门槛的问题,
再者虽然跟 TypeScript 做了配合, 但是有些地方并不很直观, 有一些思考的成本.
这个毕竟不是社区大量的投入打磨的方案, 也没有大量的文档教程支持, 导致局面有点奇怪.
如果是 Facebook 或者阿里, 搞出一套方案, 是有能力往整个社区推广的,
而我们作为小厂, 自己用就是自己的培训成本, 完全需要自己操心了.

请求的抽象

请求这个坑也算是针对简聊的场景专门设计的方案, 来源是 Netflix 的 data graph.
Netflix 具体细节我记不清了, 当时有个演讲专门说的.
思路跟 GraphQL 类似, 也是方案自动处理请求的依赖关系, 前端把相关依赖整合起来.
当年 GraphQL 非常不完善, 而且没有跟对简聊做数据推送的场景,
经过这么多年了, 社区很多公司跟进了 GraphQL 的方案, 做了很多探索.
我所在的公司后端是 Go, 而且业务较重, 我这边也就失去了跟进 GraphQL 的可能.
当前使用的方案较为原始, 但是也尽量在用生成 API 代码的方案配合 Hooks 做一些抽象.

简聊当时的数据依赖的抽象很稚嫩的, 也是因为场景简单, 所以走下去了. 可维护性也一般般.
从后面 GraphQL 的路线进行对比, 很明显, 数据抽象这个事情需要后端帮忙.
后端微服务之间很容易通过多个请求进行数据聚合, 性能优化办法也很多.
这些等到前端再想办法做抽象的, 已经算太晚了, 限制也很多, 效果也很弱.

但总体说我认为数据自动处理依赖在未来也还会是会被经常触及和深入挖掘的场景.
GraphQL 不是最终方案, 至少我们的场景就不够用. 当然更多还是靠后端了.

Form 场景

简聊因为是聊天室, 当时对于 Form 的需求很少, 或者说只有特定的一块,
当时的需求是后端想要通过 JSON 配置表单, 前端渲染就好了.
所以当时给出的也只是 React 根据 JSON 做简单的 Form 响应的方案.

后来到了别的公司, 接触到后台管理大量使用 Form 的场景, 我发现自己忽视了这整理一大块.
我这边后来开始了 meson-form 模块继续针对业务场景定制 Form 的方案.
这就跟简聊当时面对的简单的场景很不一样了, 而且 Form 后面还会有各种重逻辑的变种.

匆匆忙忙和代码重构

简聊那段时间让我形成了一个观念, 就是架构调整很少会被分配出时间来做,
所以基本上就经常要我们自己私下调研方案还有见缝插针一点点去做了.
在执行层面上就基本上这样了, 而且要一点点拆成小的任务, 不然不好推进.
而大的重构并不是说没有, 但是时间上只能尽量自己去匀自己去控制了.
而当时由于 React 生态各种东西不成熟, 而且有时候直接破旧立新, 就比较被动,
我为了能争取到主动, 花了很多心思, 但是最终反而开始脱离大部队的倾向.
现在的借我就是我倒向了 ClojureScript, 很多方案脱离社区更加严重了.

我在饿了么的精力, 现在我也不太确定, 大公司对技术研究和投入能做到怎样的节奏.
就我在小公司的话, 由于场景和业务相对单一, 我要花很长时间面对同样几个问题,
所以我可以花很多零碎的精力定制出相对深入的方案, 考虑的场景也单一一些,
而在具体操作的要尽量考虑和已有方案短期共存长期尽量替换的思路,
在后续的开发当中穿插着逐步把老的方案和实现一点点替换掉, 或者干脆不替换.
这个在具体实行的时候是非常琐碎的, 但涉及的代码也不会少.

后面我看到社区别人给出的方案, 特别是大厂给的方案, 封装做得较为完善甚至到多余.
就技术方案来说, 我很少能给出那么完善的方案, 因为打磨的时间和需求都相对少,
我的方案服务的人群也没有那么多, 公司也匀不出那么多时间让我专心去做.
但换个角度说这样持续演进而且封装不完整的方案, 对于他人接手来说显然并不好.
这个方案需要我持续 review 和提醒保证执行不出错, 而不是别人比较容易加入维护和扩展的.
这些事情说起来是要我作出转变, 规范和严谨, 但在我的角度条件又不够充足的.
将来说如果我去了大厂的话, 是否会有足够精力在一块东西上打磨呢, 我心里是有问号的.

但现在再换个角度来说由于我大量基于 ClojureScript 的研究和展开, 可以说自成一体了,
这些方案和 JavaScript 内在的那些偏离还有纠葛, 也让我操很多的新,
操心的结果对于 ClojureScript 受众以外的人群可能还不大被理解,
就这个事情算下来也是让我比较有的头疼的, 也不大乐意到被纠结在这个上边.

其他

简聊的代码多年前我就没参与了, 而且公司转向其他方向, 后面大家都知道了.
我当初离开的时候除了原来几个原因, 也因为当时 teambition 还是 Backbone 为主,
我心理上比较反对在享受了 React 的方案之后再去体验一遍 Backbone 开发的痛苦,
而且在 FP 路线走得太远特别是孤立行走太远的情况下,
加上 leader 们也不会给我足够支持去推动 React 方面的事情, 我也有点觉得无力.
后来 teambition 转 React 的事情知道的人很多, 中间的坎坷也不少,
当时我在一家用 Vue 的公司自己心思也在 ClojureScript 上边, 只能远远围观了下,
虽然事后有打听过, 但是也没机会跟前同事深入去谈那些东西, 后面关联的少了.

平心而论虽然简聊场景脱胎于 teambition, 但是后者数据规模复杂太多,
原先积累的那些工具链和方法论, 是否能在其中应用, 我心里是没底的,
而且 Lisp 程序员这种跟着场景做定制的心态, 到了不一样的规模给出的方案变化也会不小.
虽然参与的有不少当时简聊的成员, 但我也不认为我的方法论有留下多少.
从后面的研究看, 那些方法论跟 ClojureScript 的契合度只多不少,
即便我是在快离开的时候才渐渐整个人倒向 ClojureScript 阵营的...
现在很多的思路在 Respo 相关工具链中还存留着, 个人项目也还在, 但毕竟变化不少了.

另外那些延续到了后面公司的用法, 随着配合 TypeScript 的努力, 也改变了不少,
而且从后面接触的大量后台管理的表单的场景看, 简聊的单页面才是比较特殊的场景.
再说后面我也更多接触到前端场景的复杂度了, 甚至移动端和小程序我都还没开始...
除了小程序, WebAssembly 那边的事情才开了个口.. 前端真是好复杂.

查看原文

赞 2 收藏 2 评论 0

题叶 发布了文章 · 3月23日

Respo Hooks 写法的起因和示例

近期对 Respo 的状态管理方案进行了一次更新,
具体的代码可以看 https://github.com/Respo/resp...

起因

Respo 的 States 方案为了方便热替换时保存状态, 做了一些限制,

  • 组件挂载的时候以及渲染过程当中不能 dispatch 事件,
  • states 以树形的方式存储, 需要手动调用 cursor-> 在组件之间传递分支,
  • 修改状态使用的路径, 也就是 cursor, 跟组件对应, 方便简写.

React Hooks 出来的时候, 我当然意识到了 Respo 功能的不足,
之前比较久了, 我为 Respo 加上了基础的 Effects, 可以加一些 DOM 操作,
React Hooks 可以把部分的组件状态抽到插件里去, Respo 不行.
Respo 的 cursor 是跟组件绑定的, 插件写法无法传递 cursor.

另一方面, 我在 Phlox 项目当中由于需要, 设计了个简单的 states tree 方案,
这个方案里 cursor 是需要用户手工传递的, 比较啰嗦, 但是勉强够用.
后来我仔细想想, 这个方案对于 Respo 来说, 也是够用的.
虽然写起来会啰嗦, 但是用户可以手动传递 cursor, 也就意味着可以拆成插件复用.

代码示例

文档上写得也不详细, 这边稍微再描述一下.
首先 Respo 的状态树, 大致上是这样一个结构,
其中 :data 专门用于存储节点的数据, :cursor 作为保留字段,
这就是一个树形节奏, 跟组件直接对应, 但是大致跟状态分支对应上:

{
  :cursor []
  :files {
    :ns {
      :add {
        :data {:show? false, :failure nil}
        :modal {
          :data {:text nil, :failure nil}
        }
      }
      "app.rude" {
        ":rmapp.rude" {
          :data {:show? false}
        }
      }
    }
    "app.rude" {
      :add {
        :data {:show? false, :failure nil}
        :modal {
          :data {:text nil, :failure nil}
        }
      }
    }
  }
}

需要更新状态时, 需要一个 cursor, 可以是 [:file :ns "app.rude" ":rmapp.rude"],
后面还有个 :data, 有了 cursor, 就能定位到数据做更新了.
后面大致想象, 组件用的就是一个分支的数据, 而 cursor 对应分支的路径.
具体在代码当中比较啰嗦, 不过比较容易可以维护两者的对应关系.
最终得到类似组件局部状态的一个效果.

为了方便书写, 我增加了一个 >> 函数, 把 states 和 cursor 一起传递.
经过杂七杂八的抽象以后, 最终得到这样效果的代码:
https://github.com/Respo/aler...
这中间是省略了好多的过程, 也不打算很详细描述了, 具体要看文档(还没补好).

相应地, 更新状态的部分我加了 update-states 函数, 作为一个简写.
状态更新作为 dispatch action 的一种特殊情况, 跟 dispatch 一起被处理.
如果没看之前, 我明确一下, Respo 当中 states 是跟 store 一起存储在全局的.
能分支读取, 能维护 cursor 做更新, store 当中能响应, 整个流程串起来了.

延伸的影响

增加这块功能主要的目标, 跟 React Hooks 类似, 为了逻辑的复用,
Respo 的组件跟 React 类似, 不允许从外部操作状态,
这就意味着我封装出来的 Modal 组件显得比较奇怪了, 或者说死板,
要么我外边维护一个 visible 状态, 传进去, 并且加上 on-change 做切换,
要么我把触发打开关闭的部分也放进组件里边, 这样使用起来就有点僵化了.

而 Hooks 形态的写法, 开始允许状态被抽取到一个独立的函数当中,
比如我调整过的 prompt 用法, 就可以抽出的一个插件当中,
https://github.com/Respo/aler...

(defn use-prompt [states options]
  (let [cursor (:cursor states), state (or (:data states) {:show? false, :failure nil})]
    {:ui (comp-prompt-modal
          (>> states :modal)
          options
          (:show? state)
          (fn [text d!]
            (if (some? @*next-prompt-task) (@*next-prompt-task text))
            (reset! *next-prompt-task nil)
            (d! cursor (assoc state :show? false)))
          (fn [d!] (d! cursor (assoc state :show? false)) (reset! *next-prompt-task nil))),
     :show (fn [d! next-task]
       (reset! *next-prompt-task next-task)
       (d! cursor (assoc state :show? true)))}))

插件暴露 uishow 方法两部分, ui 用于渲染, show 方法用户更新状态.
这样, 以往代码当中相当多的弹层的逻辑就可以抽出做复用了.

后续代码会继续更新. 目前也认识到 Respo Hooks 相比 React Hooks 比较局限比较多,
特别是 Effects 那块, React 做得比较强大了, Respo 这方面功能很弱.
希望目前来说这个功能够用, 这样我能对 Calcit Editor 遗留的代码做一些整理.

查看原文

赞 1 收藏 0 评论 0

题叶 发布了文章 · 3月22日

关于 React Hooks 的一些使用经验和换角度反思

本文基于 https://reactjs.org/docs/hook... 功能展开

算算时间都要一年半了, React 在 2018 年推出 Hooks, 引发了热议.
印象里就是在群里面, 我就很纠结里边的黑魔法太奇怪了.. 看得小心翼翼的.
然后看着别人研究代码, 提出类似的实现之类的, 或者各种解释.
慢慢地很多不同的声音也发出来, 特别是迷之闭包, 很多人都中招了出来吐槽.
再后来, React Hooks 蔓延开来, 连 Vue 社区也开始模仿.. 看来是真重要了.
由于我没有动力去深入 React 完整的实现, 所以对细节也只是处在一个大致的了解的状态.

对于 Hooks 想要解决的问题, 我大致是认同的, React 此前的扩展功能太僵化了.
高阶组件, 虽然在 FP 里面常见的用法, 实际引入 React 搞成了 decoration 满天飞,
..太奇怪了, 一个 FP 里引入的概念, 用大量的 OOP 和 mutable 方法来实现.
而且去掉了 mixin 机制之后, React 复用逻辑的问题感觉就是个坑, 重复代码挺多的.
虽然我是不信任 React 搞这种黑魔法推翻以前的一些宣传口号的, 但是...
Hooks 确认可以帮我解决 class based 组件难以做好的逻辑复用的问题.

我在公司里推 Hooks 的时间比较晚, 已经是在活动听到别人折腾 Hooks 之后了.
最开始是 Ajax 代码复用, 网上当初那个例子很明显, 现在我们也抄了很多这种.
但是后来, 让我感受到最大变化的, 还是发现 Hooks 对我们的 Form 组件的改善,
可以先看例子 http://fe.jimu.io/meson-form/...
大致上说, 就是用 Hooks 抽离状态的话, 复用场景更加灵活, 超出组件层面...

这篇文章是我几个跟 Hooks 相关的想法梳理, 线索有点乱, 看章节吧.

如果能用 Macros 实现 Hooks?

我们知道 Hooks 最开始就明确说了, useState 等 API 调用, 跟依赖相关, 且不能用包在 if 里.
原因也不难理解, React 组件运行过程中要逐个追踪, 条件语句会破坏这个逻辑.
我用 ClojureScript 的时间挺长了的, FP 当中一般的玩法我是知道的,
一般来说, 为了代码的"引用透明", FP 当中会避免到处存在内部状态.
一个用 Hooks API 维护状态的函数组件, 本身居然有这样的状态, 很脱离 FP 常规的玩法.
而且这个, 虽然现在是习惯了, 但不能用 if 总归是在有一些限制的, 令人忌惮.

换个角度的话, 也不是说 FP 里面就没有这种状态的性的东西.
而是说, FP 当中状态是习惯于显式跟普通的计算区分开的,
你要用状态, 就明确声明这边有个状态, 大家调用都注意区分. 不然, 那就是纯的计算.
或者, 切换一个层次说, 你这不是代码, 而是 DSL, 这个 DSL 提供新的一层抽象.
DSL 相当于构建一套原先的代码之上的一层新的语义, 那无所谓 FP 不 FP 了.
可以把深层的逻辑通过复杂的手段约束在 DSL 语义内部, 上层就是使用 DSL 描述.
应该说我理解 React Hooks 就是这样的一个状态, 语言之上的 DSL.

作为 DSL 说的话, 我觉得 Svelte Vue 那样倒是更自然了.
倒不是说它们提供的方案一定比 React 好, 但是有一个编译阶段, DSL 就更完整,
首先 DSL 某个语义未必是一个函数就能实现的, 可能需要增加比较绕的代码,
其次, 提供语义也就意味着对用户的术语有约束, 就需要编译阶段做检查做语义的验证,
但 React Hooks 这边, 这就是 runtime 插入功能, 剩下让人们自己去约束了.

当然, 我持有的观点, 有一点是 JavaScript 没有 Macros, 限制了 Hooks 的设计.
我用 ClojureScript 作为 Lisp, 比较容易修改语法树, 展开简单的代码,
(应该说 Lisp 这种, 能力也远不如 Babel 甚至 Svelte 那么强大, 只能说方式很廉价.)
Hooks 的设计, 在增强功能的同时, 很大程度是想着保留 API 的简洁.
如果有 Macros 可以用的话, Hooks 可以考虑的写法还有很多,
比如说定义组件的时候写成,

(defcomponent c-demo [x y] (div {} (str x y)))

然后由 Macros 系统展开成函数, 并且由执行环境诸如几个变量(影响到 ts 什么我就不管了),

(defn c-demo [x y]
  (fn [use-state use-effect internals]
    (div {} (str x y))))

我们目前需要全局引用 useStateuseEffect 然后心里去想着那是运行时的东西如何如何,
但是从上面的展开例子, 从 Macros 的角度理解, 很容易知道这些是运行环境控制的操作,
这样就没有原先 React Hooks 那种让人产生些许错觉的写法了.
(有可能语法展开跟函数执行的区别对非 Lisp 程序员比较难区分... 大致按 Babel 去想吧.)

另一个是状态追踪的问题 Hooks API 依赖的是 useState 的顺序.
我个人比较倾向于用名称去控制这多个状态, 在用户端有更多控制能力.
而这部分在跟 Vue 和 Svelte 对比会发现 React 这边能设计出更多的花样.(没去了解具体实现)
而 React 使用函数作为唯一的手段, 就显得非常, 或者还是逃不脱被 DSL 要求存在的限制.
当然话说回来, React Hooks 单调的方式能被设计出来满足这么多功能, 也是超乎我想象了的.
同时也由于抽象手段单一, 就非常依赖 runtime 内部实现奇奇怪怪的手法去支持功能.

Respo 拙劣的山寨

Respo 是我个人项目使用的 cljs 之上的 Virtual DOM 方案. 并不基于 React.
这样我就有机会试验我自己思考的一些组件和状态的抽象方案.
Respo 里边, 为了保存热替换过程中组件的状态, 设计了用户态的"状态树"的概念,
用户在定义组件的时候, 大致上对一个 Todolist 会在全局构建和存储这样的树形结构:

{
  :data {}
  :todolist {
    :data {:input "xyz..."}
    "task-1-id" {:data {:draft "xxx..."}}}
    "task-2-id" {:data {:draft "yyy..."}}}
    "task-2-id" {:data {:draft "zzz.."}}}
}

组件代码当中需要比较啰嗦的声明,

; for app
(comp-todolist (>> states :todolist))

; for single task
(comp-task (>> states (:id task)) task)

另外具体使用还要比较啰嗦的声明,

(let [state (or (:data states) {:input ""}]) "TODO whole list")

(let [state (or (:data states) {:draft ""}]) "TODO Task")

以及在组件层级之间传递 cursor 的路径位置, 以便发起更新...
相比 React 任何一种多了很多的代码, 只能说为了热替换稳定做了非常拙劣的模仿,
然后, 再次之后, 我也能在这个方案上, 也提供类似 Hooks API 的状态抽象,

(defn use-menu-feature [states options]
  ; TODO
  {:ui "TODO",
   :effect-fn "TODO",
   :edit-fn "TODO"})

(menu-A (use-menu-feature (>> states :menu-a) {}))
(menu-B (use-menu-feature (>> states :menu-b) {}))

这是一个脱离了 Macros 和编译方案, 也脱离了 React 内部状态黑魔法的方案,
而这样简单啰嗦的方案, 切切实实也能模仿出 Hooks 核心的一些功能来.
(当然在整体功能上, 跟 React 不能比, 而且要实现的话也依赖 runtime 做.)

这个例子对于 React Hooks 本身没有什么帮助或者阐释,
主要是从另一个角度去看, 作为一个前端 MV* 方案, 怎么看待其中的核心需求和实现.
各种方案在各种需求点的路径探索以及取舍, 脱离一下限定的视角, 会有其他主意.
或者说, 没有 Hooks 甚至没有 React 时, 这种逻辑复用你通过何种方式达成?

业务当中内秉的状态

回到 Hooks 本身, 我目前在的业务当中探索使用的方案, 大致可以参考 Form 这个例子,

let formItems: IMesonFieldItem[] = [
  { type: 'input', name: "name", label: "名字", required: true },
  { type: 'select', name: "city", options: selectItems, label: "城市" },
];

let [formElements, onCheckSubmit, formInternals] = useMesonItems({
  initialValue: {},
  items: formItems,
  onSubmit: (form) => {
    console.log('After validation:', form);
  },
});

// 返回的 formElements 用于 UI 渲染,
// formInternals 包含几个方法, 比如 resetForm 重置组件状态

或者也参考弹出提示的这个例子,

let [ui, waitConfirmation] = useConfirmModal();

let onClick = async () => {
  let result = await waitConfirmation({
    text: "节点可能包含子节点, 包含子元素, 删除节点会一并删除所有内容.",
  });
  console.log("result", result);
}

// 点击调用 onClick 时, 修改 confirmModal 的内部状态, 打开/关闭
return <div>
  <button onClick={onClick}>Confirm</button>
  {ui}
</div>

相较于 React 组件以往的抽象方案来说, Hooks API 暴露出来了一个从前我们熟悉的功能.
就是: 可以修改抽象模块的内部状态了.
以往 React 宣传的, 组件作为抽象方式, 内部的状态是封闭的, 外部不应该去操作.
比如就一个弹窗的 Modal, 你就需要外边有个 visible 的状态去控制, 传进 props, 显示, 还是不显示,
但是有了 Hooks, 这时候你可以把状态封进 Hooks API 内部, 然后暴露回调函数去操作.
可是你又明显能知道, 这边封装了一个状态.
至少从前 React 并不鼓励这种状态, 或者应该说, 此前并不存在简单的这样的功能.

反观我们实际业务当中, 局部状态封装却是很常见的东西,
比如 visible, 以往我们通过插件去实现的时候, 不就是把状态藏在插件内部的吗,
然后得到一个 .toggle() 的方法, 然后可能还会多个地方去 if (modal.visible) {} 一次次探测.
当 React 提出需要一个父组件存一个 visible 还有加一个 onChange 回调的时候, 让人觉得很怪异,
最终我们希望暴露给业务当中使用的时候, 明显 .toggle() 才是极为简短清晰的方案.

顺着这个思路, 怪异的事情来了, jQuery 时代, 我们欢快地 .toggle(),
React 来说, 说 rethink, 然后我们加了 visibleonChange, 然后 React 真香,
现在基于 Hooks 方案, 封装局部状态的方案又开启了... 历史又陷入螺旋了.

回过头去说 jQuery 插件形式的抽象, 是不是好呢, 显然当时使用的体验并不好,
由于有状态, 可能多个位置都想要去控制这个状态, 就需要多次判断状态, 要开还是关,
React 所做的, 首先有一点, 状态集中数据这边来, 尽量控制单向自动流动,
同时通过 data-driven 的思路和实现, 减少手动同步的状态的工作量.
这一点来说, React Hooks 比起当初 jQuery Plugin, 是要方便很多的.

其他

我一直有种感觉, React 选择 FP 这条路的时候, 其实没那么清晰,
探索了那么多方案, 强行把函数式的一些说法套用到前端这边来, 可能很多人都不能理解准确吧,
你说是 stateless 还是 state isolation, 细小的差异, React 这边并没有梳理清楚.
真的函数式语言, PureScript Elm 怎么写的前端, 真的是 React 这样子的吗?
我为了概念稍微准确点, 转向了 ClojureScript, 实际用下来 React 跟它还是差异挺大的.
在状态这个事情上, React 有了这次的调整, 未来谁说得准要不要再调整一次呢.

或者就是问一句, 当我们拿 React 往 FP 去套的时候, 是否要把 Component 往 Function 去套呢,
让 Components 跟 Functions 那样能高阶组合, 传递闭包之类的?
我觉得从现在看, Component 不是对应 Function, Component 是 DSL 中的一个抽象,
这个抽象可以继续扩展出 states, effects 等等, 比起 Function 复杂太多.
我们用 FP 手段去提升整个 MV* 方案, 是用在具体实现和优化的层面, 并非取代组件这个观念.

就我在 ClojureScript 的使用经验而言, FP 能提供非常强大的表达能力, 便于开发,
但是代价是大量的内存申请, 而这也是 React 不得不努力去做性能优化的原因.
对 FP 语言来说, 这样的优化要大量再语言层面实现, 编译器, runtime, 打包, 到处优化,
对于 JavaScript 来说, 把自己优化成另一门语言, 显然是不切实际的事情.
所以我对 JavaScript 生态之上的 React, 始终认为是存在隐患而且越走路越窄的.

至于 Virtual DOM 带来的 Model->View 那种 date-driven 的模式,
换个装逼的说法 "DOM 更新方案的自动化", 从 Angular/React 真的普及到了整个前端领域.
这个是实打实的提升. 现在混坑爹很难想象谁还会去用主打手动更新 DOM 的前端框架了.
而在此之上的状态管理, 简直是一片混战, 即便在 React 生态内部, Mobx 也是横生枝节.
基于不可变数据的, 基于 observable 的, 可能大家就是相互看不惯吧...

查看原文

赞 4 收藏 1 评论 0

题叶 发布了文章 · 2月10日

Clojars 用 depstar 和 deps-deploy 发包的记录

因为考虑 CI 做部署的事情, 在网上问了一下, 有社区的高手给了个方案:

https://clojureverse.org/t/gi...

I would expect this to be possible since lein, boot, and CLI/deps.edn all have ways to build an uberjar and publish it to Clojars. (for the latter, see https://github.com/clojure/to... 3 – I use https://github.com/seancorfie... 2 and https://github.com/slipset/de... 4 for build/deploy)

配置 Meyvn 的时候, 安装过程询问是否要证书, 选了 NO, 然后就退出了.
后来又尝试了下 https://github.com/juxt/pack.... 安装过程就出错了.

高手提供的方案是有两个脚本做打包和发布, 他使用的, 比较稳定的,

https://github.com/seancorfie...
https://github.com/slipset/de...

具体的打包发布步骤基本对应 https://juxt.pro/blog/posts/p...

跑通以后目前的 deps.edn 配置:

{:paths ["src"]
 :aliases {:depstar {:extra-deps {seancorfield/depstar {:mvn/version "0.5.2"}}
                     :main-opts ["-m" "hf.depstar.jar" "target/lilac.jar"]}
           :deploy {:extra-deps {deps-deploy {:mvn/version "RELEASE"}}
                    :main-opts ["-m" "deps-deploy.deps-deploy" "deploy" "target/lilac.jar"]}
           :install {:extra-deps {deps-deploy {:mvn/version "RELEASE"}}
                     :main-opts ["-m" "deps-deploy.deps-deploy" "install" "target/lilac.jar"]}}}

启动命令:

    "m2": "clojure -A:depstar && clojure -A:install",
    "deploy": "clojure -A:depstar && clojure -A:deploy",

pom.xml 文件是 clojure -Spom 生成的, 但是中间内容要修改, 包括增加部分字段,

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>mvc-works</groupId>
  <artifactId>calcit-workflow</artifactId>
  <version>0.0.1-a1</version>
  <name>calcit-workflow</name>

  <url>https://github.com/mvc-works/calcit-workflow</url>
  <description>TODO</description>
  <scm>
    <url>https://github.com/mvc-works/calcit-workflow</url>
  </scm>

  <dependencies>
    <dependency>
      <groupId>org.clojure</groupId>
      <artifactId>clojure</artifactId>
      <version>1.10.1</version>
    </dependency>
  </dependencies>
  <build>
    <sourceDirectory>src</sourceDirectory>
  </build>
  <repositories>
    <repository>
      <id>clojars</id>
      <url>https://repo.clojars.org/</url>
    </repository>
  </repositories>
</project>

单纯 pom.xml 的内容结构是很复杂的, 参考:
https://github.com/seancorfie...
https://github.com/clojure/cl...

没有打算提供完整的 pom 文件了. Clojars 上显示的内容也不完整.
目前只是想做到能发包和使用, 维护尽量简单可重复.
毕竟影响到的项目太多了.

另外 deps-deploy 账号密码是通过 env 提供的, 具体看 README.
对应 CI 来说基本还是可以配的, 管理来说却是不如 token 合适.

查看原文

赞 0 收藏 0 评论 0

题叶 发布了文章 · 1月12日

九章编程: 文言文编程的 Cirru 实现的一种试验

本文是对于 wenyan-lang 方向在的一些尝试, 利用 Cirru 的工具链, 做简化的方案.
代码实现看九章编程, 以及对应的 Demo 页面.

基于九章编程的方案, 最终实现一个 Fibonacci 函数, 代码是这样的,

术曰 菲氏数 (甲)
  若 (少于 甲 三) 一
    并 (菲氏数 (减 甲 一)) (菲氏数 (减 甲 二))

得 (菲氏数 一)

对比一下文言编程的例子, 会显得后者啰嗦很多.

吾有一術。名之曰「斐波那契」。欲行是術。必先得一數。曰「甲」。乃行是術曰。
    若「甲」等於零者乃得零也
    若「甲」等於一者乃得一也
    減「甲」以一。減「甲」以二。名之曰「乙」。曰「丙」。
    施「斐波那契」於「乙」。名之曰「丁」。
    施「斐波那契」於「丙」。名之曰「戊」。
    加「丁」以「戊」。名之曰「己」。
    乃得「己」。
是謂「斐波那契」之術也。

施「斐波那契」於十二。書之。

当然了, 九章编程方案只是取巧地把 Lisp 的写法翻译成中文而已, 少了很多.
首先这东西挺好玩的. 再就是自己仔细看下来也有不少自己的想法.

古文用法的一些想法

翻了代码里的问题, 文言编程几个作者古文词汇和句式都比我丰富挺多的.
不过因为现在的人都不是常用古文, 其实也挺不正宗的.
大致能感受到例子里很多用法, 是混杂了不同朝代的措辞, 所以有点怪.
而且有些无法解决的问题, 西方传入的数学和计算机术语, 免不了要用现代的词汇.

当然, 按照我的偏好, 如果说有古文编程的话, 我首先想到用古代典籍作为模板.
比如说九章算术, 至少汉代的著作了, 这应该能充分代表古人对于数学的表达习惯.

〔三七〕今有環田,中周九十二步,外周一百二十二步,徑五步。問為田幾何?
荅曰:二畝五十五步。

〔三八〕又有環田,中周六十二步、四分步之三,外周一百一十三步、二分步之一,徑十二步、三分步之二。問為田幾何?
荅曰:四畝一百五十六步、四分步之一。
術曰:并中外周而半之,以徑乘之為積步。
密率術曰:置中外周步數,分母、子各居其下。母互乘子,通全步,內分子。以中周減外周,餘半之,以益中周。徑亦通分內子,以乘周為實。分母相乘為法,除之為積步,餘積步之分。以畝法除之,即畝數也。

以及元代的四元玉鉴当中也有类似的表达习惯.(没有句读太难读了..)
我觉得如果这些数学家当年发明编程语言的话, 怕是跟这差不了多少.
不过也还好现在的编程语言有各种标点符号, 不然真是有的受了.

另外比较明显的一个麻烦是, 代码当中必然会有较多的抽象, 或者说定义函数,
即便大家用的不是 Lisp 这样的前缀表达式语言, 也少不了会遇到这样的代码,

(f1 p1 p2)
(f2 q1 q2 q3 q4 (f3 a5 q6) (f4) q7 q8)

算了我还是换个你们好接受一些的写法:

f1(p1, p2)
f2(q1, q2, q3, q4, f3(a5, q6), f4(), q7, q8)

在文言编程当中, 可以看到 wenyan 用了 名之曰「丁」 来定义操作,
而实际上对应这种枯燥的抽象, 基本上很难也古文自然得表达出来.
或者说代码, 作为给机器执行的语言, 本身就有着特殊性.

当然, wenyan 能定义出这么一整套来, 还是挺厉害的.

在九章编程当中, 我出于省事的考虑, 直接基于已有的 Lisp 风格直接做了.
也就是说, 九章编程基本上就是基于前缀表达式实现的. 不像自然语言.
但是具体的术语, 我基于九章算术的文本做了简单的统计, 选取了一些词汇,
总得来说只是借了一层九章算术花样, 比如"对象", 九章算术里压根就没这东西.

中文数字和变量名的一些处理

我看 wenyan 当中用的中文数字表示, 在源码里有自己去解析和搜集.
翻了一下代码, 大概是自己进行了解析吧, 先转成阿拉伯数字表示, 就很快了.
九章编程里面直接找了个模块 nzh 进行转化的, 做 Demo 也够用.

另一个是变量名的问题, 九章编程直接用中文字符串做的.
因为九章编程实际上是 interpreter, 不是转义 js 的, 没这个限制.
wenyan 的实现当中我看到有转成拼音的操作, 不确定具体情况.
按说中文, 都是 jia3, 虽然有字典, 但很容易会重名的.

JavaScript 方言的实现方案

wenyan 大致上提供了 js, py, ruby 的方案, 大致看了一下 js 的部分.
首先 tokenize, 再 parser 解析代码, 然后用 compiler 拼接 js.
拼接 js 的部分是直接用的字符串拼接, 相对来说不那么完善, 但是够用.
另外手动拼接得到的代码, 一般格式都是乱的, 需要用 Prettier 或者 Babel 重新格式化.

另外比较省事而且可靠点的方案是用 babel/generator 去实现.
就是说用 Babel 来处理 JavaScript 代码生成的具体实现,
这样几方的工作只要做到能生成 AST 就好了, 这就安全很多.
比较熟悉是因为我的 CirruScript 用的就是这套方案, Babel 工具链真挺丰富的.

九章编程用的方案是 Interpreter, 解释执行, 没有生成 js 代码.
这也就意味着执行计算都是在 JavaScript 运行环境内部的,
单纯 JavaScript 执行, 可以有 V8 优化, 最终甚至可能以汇编的形式运行,
那样来说性能就好很多了. 解释执行的问题就是性能会很差.
不过另一方面, 解释执行不需要满足 js 语法, 也就没七七八八的限制了. 直接跑.

Cirru 提供的方案

虽然对于编译器来说, 生成代码的优化是最难的部分, 但玩具项目的话...
要写个 Parser 把整个代码结构解析出来也是相当要命的工作量.
wenyan 光是 parser.js 就八百多行了, 还不算各种工具函数和关键字定义的,
没看明白 typechecker.js 具体逻辑, 校验结构么, 也快七百行了.
反而 compiler 生成 js 部分三百多行就搞定了...

我...毕竟是写着玩的, 如果 Parser 也要这么风风火火折腾一遍, 枯燥啊.
不过我有 Cirru 这边的工具链, 加上语法, 直接用 Lisp 风格套上去了.
Cirru 大致是是一套把缩进语法(或者数据)生成一个树结构的方案,
比如这样一段代码, 直接用 Cirru 的模块进行解析,

得 (菲氏数 一)

就能直接得到一个树形的结构:

[
  [
    "得",
    [
      "菲氏数",
      "一"
    ]
  ]
]

前面函数定义的部分, 代码复杂一些, 有缩进, 也对应解析出来:

术曰 菲氏数 (甲)
  若 (少于 甲 三) 一
    并 (菲氏数 (减 甲 一)) (菲氏数 (减 甲 二))

得到着要一个树形的结构:

[
  [
    "术曰"
    "菲氏数"
    ["甲"]
    [
      "若"
      ["少于" "甲" "三"]
      "一"
      [
        "并"
        [
          "菲氏数"
          ["减" "甲" "一"]
        ]
        [
          "菲氏数"
          ["减" "甲" "二"]
        ]
      ]
    ]
  ]
]

有这样一个结构, 后面的部分就相对容易了, 如果不校验的话, 直接就能算, 比如:

["减" "甲" "一"]

经过简单的变换就能得到对应的 JavaScript 代码:

(甲 - 1)

或者更加复杂一些的结构,

[
  "并"
  [
    "菲氏数"
    ["减" "甲" "一"]
  ]
  [
    "菲氏数"
    ["减" "甲" "二"]
  ]
]

其实就是判断一下, 对中间的数组进行递归计算, 也很容易完成求值.
当然, 具体到函数定义方面, 以及一些动态长度(或者复杂节够)的语句, 会麻烦一些.
原理上可以参考 http://norvig.com/lispy.html 提供的例子.

这套方案用了 Cirru 的 Parser, 同时也就继承了 Cirru 语法的约束,
比如用 ()$," 以及空格进行语法结构控制的事情.
放在九章编程里面, 主要是在古文编程当中插入了大量的英文符号...
或者说这些如何其实除了空格换成中文笔画符号.. 可能效果也是类似的, 总之有些奇怪的东西.
不过总体上讲, 直接省去了大量工作量.

其他

wenyan 高亮做得比较充分, 渲染图挺漂亮的. 九章这边没有专门做.
不过倒也不是一点高亮都没有, 可以看到文档里直接用 Cirru 进行了基础的高亮.
看源码那边, 好像 wenyan 用的是 SVG 渲染的图, 效果确实不错.

因为是个玩具项目, 九章编程试验到能求 Fibonacci 然后, 有点玩不动了.
我知道后面的工作量挺多的, 比较我之前 Cirru 项目当中就在尝试.
有兴趣过来 watch 一下 CirruScriptinterpreter.nim 这边的工作.
虽然暂时没有经历深入开发, 但是断断续续会王中间追加功能的.

另外我在微博上也有提到, 中文的表达能力其实是非常强的,
可以想象, 同个含义, 在古文当中可以得到多个表述, 排列组合下来, 不比英文少...
以往看到的中蟒算是做的比较完整的一个范例吧.
完全有很多可能, 可以脑补一个少儿编程的场景, 写一段代码,

李雷的数 是 1
韩梅梅的数 是 2
(李雷的数 加上 韩梅梅的数) 是多少

这个代码用 Cirru 能解析出来一个简单的结构,

[
  [
    "李雷的数",
    "是",
    "1"
  ],
  [
    "韩梅梅的数",
    "是",
    "2"
  ],
  [
    [
      "李雷的数",
      "加上",
      "韩梅梅的数"
    ],
    "是多少"
  ]
]

做一下语法转换, 就得到一串很熟悉的前缀表达式了,

[
  [
    "是",
    "李雷的数",
    "1"
  ],
  [
    "是",
    "韩梅梅的数",
    "2"
  ],
  [
    "是多少",
    [
      "加上",
      "李雷的数",
      "韩梅梅的数"
    ]
  ]
]

然后求一下值, 拿去忽悠一零后有没有效果...

换成中文门槛也低一些吧, 应该有不少可以尝试的.
九章编程的 Demo 是可以执行的, 欢迎试玩 http://jiuzhang.cirru.org/

查看原文

赞 1 收藏 1 评论 2

题叶 发布了文章 · 2019-12-31

折腾前端条形码(Barcode)扫描识别, 笔记

@zxing/library 方案(不推荐)

本地勉强把 Demo 在 React 里面跑通, 但是不好控制开始结束, API 不明确.
实际识别率很低. 我是用手机屏幕放的条形码, 大概也有影响.

https://github.com/aleris/zxi...
https://github.com/zxing-js/l...
https://github.com/zxing-js/l...

Quagga2 方案(推荐)

  • QuaggaJS

应该是 GitHub 上星星最多的, 但是没有人维护了.

https://serratus.github.io/qu...

  • Quagga2

fork 版本的 QuaggaJS. 一直有更新, 但是没有维护全部的细节, 我在运行 examples 遇到了问题.

初步认为是传入到 Worker 里面执行的代码不完整. 没有把 Quagga 的源码提前传送过去.
后面作者先把 worker 功能关掉了.. 至少界面不报错了.

有一个用了旧版本的 React 组件的 demo https://github.com/ericblade/...

自己封装了一个版本
https://github.com/jimengio/q...

Barcode Detector 方案(兼容性问题)

Google 提供的方案, 内置 API, 但是只有 Chrome 支持, 而且手动测试发现需要 80 版本.

https://web.dev/shape-detection/
https://wicg.github.io/shape-...

这个方案不完整, 需要添加 API, 手动处理前面抓取图片的部分, API 已经比较完善的,

https://developers.google.com...
https://medium.com/@immanubha...

整理了一个 Demo
https://github.com/jimengio/j...

zbar + WebAssembly 方案(继续调研)

https://barkeywolf.consulting...
https://github.com/jjhbw/barc...

这个方案同时能识别条形码跟 QR Code, 这一点比较好.

https://barkeywolf.consulting...

不过 issue 里的问题似乎文章作者也没给修掉, 是否在维护也要考虑.

https://github.com/jjhbw/barc...

按着教程试了一下, 需要 docker 里跑 C 编译. 距离打包到 Webpack 里用还有距离.

其他

暂时先 Quagga2 吧. C 比较陌生, 要不然应该试一下 zbar 的, 远期看需要.


关于积梦前端的模块和工具可以查看我们的 GitHub 主页 https://github.com/jimengio .
招聘的计划和条件也在 GitHub 上有给出 https://github.com/jimengio/h... .

查看原文

赞 5 收藏 1 评论 0

题叶 发布了文章 · 2019-10-29

ruled-router 生成路由类型细节记录

ruled-router 是我们(积梦前端)定制的路由方案, 另外强化了类型方面,
之前的介绍可以看文章: 积梦前端的路由方案 ruled-router.

关于跳转方法的类型

路由生成部分, 大致上就是对于规则:

[
  {
    "name": "a",
    "path": "a",
    "next": [
      {
        "name": "b",
        "path": "b/:id"
      }
    ]
  }
]

会通过脚本生成路由的调用方法, 现在的生成结果是:

export let genRouter = {
  a: {
    name: "a",
    raw: "a",
    path: () => `/a`,
    go: () => switchPath(`/a`),
    b: {
      name: "b",
      raw: "b",
      path: (id: string) => `/a/b/${id}`,
      go: (id: string) => switchPath(`/a/b/${id}`),
    },
  },
};

这样可以通过调用方法来进行路由跳转,

genRouter.a.b.go(id)

这个步骤, 是有类型支持的. TypeScript 会检查整个结构, 不会有错误的调用.
也就是说, 所有的调用, 按照这个写法, 不会导致出现不符合路由规则的路径.
整个实现模块维护在 https://github.com/jimengio/r... .

解析结果的类型问题

现在的短板是在解析解析结果的类型上面, 回顾一下 ruled-router 解析的结果,
对于路径:

/home/plant/123/shop/456/789

按照路由规则做一次解析,

let pageRules = [
  {
    path: "home",
    next: [
      {
        path: "plant/:plantId",
        next: [
          {
            path: "shop/:shopId/:corner"
          }
        ]
      }
    ]
  }
];

会得到一个 JSON 结构,

{
  "raw": "home",
  "name": "home",
  "matches": true,
  "restPath": ["plant", "123", "shop", "456", "789"],
  "params": {},
  "data": {},
  "next": {
    "raw": "plant/:plantId",
    "name": "plant",
    "matches": true,
    "restPath": ["shop", "456", "789"],
    "params": {
      "plantId": "123"
    },
    "data": {
      "plantId": "123"
    },
    "next": {
      "raw": "shop/:shopId/:corner",
      "name": "shop",
      "matches": true,
      "next": null,
      "restPath": [],
      "data": {
        "shopId": "456",
        "corner": "789"
      },
      "params": {
        "plantId": "123",
        "shopId": "456",
        "corner": "789"
      }
    }
  }
}

这个 JSON 结构当中部分字段是固定的, 部分是按照规则定义的参数,
如果用一个类型来表示, 就是:

interface IParsedResult<IParams, IQuery>

这也是我们以往的写法. 这个写法比较稳妥, 但是问题就是书写麻烦,
路由比较多, 需要手写的 IParamsIQuery 比较多, 也难以维护.

当前尝试生成路由的方案

对于这个问题, 我想到的方案, 主要是能不能像前面一样把类型都生成出来,
大致想到的是这样一个方案, 生成一棵嵌套的路由的树,
https://gist.github.com/cheny...
我需要这棵树满足两个需求,

  • 能得到一个完整的路由, 其中的 next: A | B | C 能罗列所有子路由类型,
  • 我能通过 x.y.z.$type 来获取其中一棵子树, 因为子组件需要具体一个类型,

这个方案最重要的地方就是需要 VS Code 能推断出类型进行提示,
经过调整以后, 得到一个可用的方案, 基于这样的规则,

[
  {
    "path": "a",
    "queries": ["a"],
    "next": [
      {
        "path": "b",
        "queries": ["a", "b"]
      },
      {
        "path": "d"
      }
    ]
  }
]

生成的类型文件的是这样:

export type GenRouterTypeMain = GenRouterTypeTree["a"];

export interface GenRouterTypeTree {
  a: {
    name: "a";
    params: {};
    query: { a: string };
    next: GenRouterTypeTree["a"]["b"] | GenRouterTypeTree["a"]["d"];
    b: {
      name: "b";
      params: {};
      query: { a: string; b: string };
      next: null;
    };
    d: {
      name: "d";
      params: {};
      query: { a: string };
      next: null;
    };
  };
}
  • 顶层的路由

页面首先会被解析, 得到一个 router 对象

let router: GenRouterTypeMain = parseRoutePath(this.props.location.pathname, pageRules);

router 的类型是 GenRouterTypeMain, 这个类型是顶层的类型,
这个例子当中只有一个顶级路由,

export type GenRouterTypeMain = GenRouterTypeTree["a"];

实际当中更可能是多个可选值, 就像这样

type GenRouterTypeMain = GenRouterTypeTree["a"] | GenRouterTypeTree["b"] | GenRouterTypeTree["c"];
  • 组件使用的子路由

子组件当中, props.router 的类型对应的是子树的某一个位置,
这里的 next 因为用了 Union Type, 不能直接引用其中某个 case,
就需要通过另一个写法, 从数据的路径上直接通过类型访问, 比如:

GenRouterTypeTree["a"]

更深层的子组件的类型, 比如嵌套的第二层, 就需要用:

GenRouterTypeTree["a"]["b"]

不过这个在组件定义当中并不直接是拿到, 因为在 props 可能无法确定类型,
就需要通过父级的 next 来访问, 具体是一个 Union Type:

let InformationIndex: FC<{
  router: GenRouterTypeTree["a"]["next"] }
  // next type
  // GenRouterTypeTree["a"]["b"] | GenRouterTypeTree["a"]["d"]

> = (props) => {
  // TODO
}
  • 配合 VS Code 做类型推断

为了能让 VS Code 从 next 推断出类型, 需要同 switch 语句判断,

if (props.router) {
  switch (props.router.name) {
  case "b": // TODO, router: GenRouterTypeTree["a"]["b"]
  case "d": // TODO, router: GenRouterTypeTree["a"]["d"]
  }
}

效果大致上,

  • case 后面的字符串在一定程度上可以自动补全和类型检查,
  • case 后面, router 类型确定了, paramsquery 就能有字段的提示和检查了,
  • 如果内部有子组件 <A router={router.next} />, router.next 会被类型检查.

当然这些主要还是提示的作用, 并不是完全跟 router 对应的类型, 不然结构会更复杂,
我试着在已有的组件项目当中做了尝试, 包括比链接更大的项目, 基本是可用的,
https://github.com/jimengio/m...

其他

目前来说, 能对项目路由进行检查, 就算是达到了最初的类型的目标,
至少能够保证, 开发当中, 使用生成的路由, 能提示和检查 paramsquery 中的字段,
并且提交到仓库的代码, CI 当中能检查到参数, 做一些质量的保证.

case 当中能够提示字符串, 算是意料之外的一个好处吧.
不过这个也要注意, VS Code 推断的能力有限, 只能用 switch 这个简单的写法,
再复杂一些, 比如嵌套了表达式, 或者往子路由数据再判断, 就推断不出来了.

当前比较担心的是项目当中出现深度嵌套的路由, 加上字段名称长, 整体会非常长:

GenRouterTypeTree["a"]["d"]["e"]["f"]["g"]

由于我们最大的项目当中曾在深达 6 层的路由, 不能不担心会出现超长的单行路由...
后面再想想有没有什么办法继续做优化..


其他关于积梦前端的模块和工具可以查看我们的 GitHub 主页 https://github.com/jimengio .
目前团队正在扩充, 招聘文档见 GitHub 仓库 https://github.com/jimengio/h... .

查看原文

赞 1 收藏 0 评论 0

题叶 发布了文章 · 2019-10-26

Respo 增加 Effects 功能支持

补了一些关于 Respo Effects 的文档, 英文细节有点吃力,
https://github.com/Respo/respo/wiki/defeffect
关于 Respo 的设计思路和功能取舍, 这边可以再描述详细一些.

新增的写法

比方说有个组件要增加副作用,

(defcomp comp-a [x y z]
  (div {}))

这次更新以后, respo.core 当中新增了一个 defeffect 的宏用来定义副作用,
defeffect 需要的不单单是多个参数, 而且是很多组参数.

(defeffect effect-a [x y] [action el *local]
  (println "effects"))

[x y] 当然就是参数了. 框架渲染过程当中会自动插入参数的值,
另外框架会插入 action 表示 :mount:update:unmount,
以及 el 是组件根节点, 也是由框架获取.

这个宏的实现, 就是把代码转换成一个函数, 函数返回的是个 HashMap,

(defmacro defeffect [effect-name args params & body]
  `(defn ~effect-name [~@args]
    (merge respo.schema/effect
     {:name ~(keyword effect-name)
      :args [~@args]
      :coord []
      :method (fn [[~@args] [~@params]]
                ~@body)})))

上面定义得到的 effect-a 就是一个函数, 可以通过 (effect-a x y) 调用,
在组件当中使用的时候, 就是把返回值变成数组, 在数组当中加上副作用

(defcomp comp-a [x y z]
  [
   (effect-a x y)
   (div {})
  ])

后面就依靠 Respo 的渲染代码, 内部进行判断, 对 effect 进行处理.

由于 effect 没有直接区分开不同的生命周期, action 使用时需要自行判断,

(case action
  :mount (do)
  :update (do)
  :unmount (do)
  (do))

*local 的存在, 是为了应付可能存在的存储局部状态的需求.
比如在 mount 的时候创建的数据, 如果在 update 和 unmount 需要用到,
目前的设计当中, 就需要组件提供私有的状态用于传递.
需要注意, 这个 *local 实际上对应的 React 当中的 ref,
也就是说, 在 *local 上修改数据, 不会出发 rendering 的行为.

以往的纯组件

React 当中组件定义的方式比较简单,

(defcomp comp-a [x y]
  (div {}
    (div {} (<> "DEMO"))))

然后会经过一次宏展开, 宏的实现是

(defmacro defcomp [comp-name params & body]
  `(defn ~comp-name [~@params]
    (merge respo.schema/component
      {:args (list ~@params) ,
       :name ~(keyword comp-name),
       :render (fn [~@params]
                 (defn ~(symbol (str "call-" comp-name)) [~'%cursor] ~@body))})))

上面的组件经过 (comp-a x y) 这样的调用之后, 会得到一个 HashMap,

{:name :comp-a
 :args '(x y)
 :render (fn [x y]
           (defn call-comp-a [%cursor]
             (div {}
               (div {} (<> "DEMO")))))}

可以看到其中没有实现生命周期的信息.
这个高阶函数在运行时会继续被处理, 添加所需的参数, 再被计算.

这个结构当中并没有预留跟 React 相似的组件生命周期,
而且也不适合用方法进行扩展, 所以比较难直接有 React class 组件那种写法.

想法和尝试

如果需要在组件当中支持副作用的, 至少要在组件的表上加上 effects 的位置,

{:name :comp-a
 :args '()
 :render (fn [])
 ; add
 :effects [(fn [])]}

原先的 defeffect 的 API 写出来, 我大致确定了需要哪些参数,
比如 [a b] 参数, 界面渲染和更新当中使用,
然后是 action 用来判断生命周期, el 对应 React 当中的 DOM Ref 用.

有了 defeffect 之后我在考虑, 都是把 effect 插入在 DOM 树当中,
类似 (div {} (effect-a x y) (div {})) 这样,
但具体看了实现, 涉及到 DOM Diff 的实现有很多坑, 也就作罢了.
于是想怎样才能以兼容已有的写法的方式吧副作用插入进去.. 最简单就是数组了.
用数组的话, 可以插入多个 effect, 并且后续也有些许继续扩展的能力.

这套写法跟 React Hooks 比起来, 有不少的功能缺失.
特别是 Respo 当中, 基本没有运行渲染过程再 dispatch actions 的可能.
React 当中频繁有 componentDidMount 或者 useEffect, 在任何时候修改组件状态,
而且也没有限制在这种生命周期时 dispatch actions.
Respo 里不认可这样的做法, 这样会持续衍生出 actions 来.
特别是在 Time Traveling 的场景当中, 这种 actions 就是破坏性的,
一旦切换到旧的某个 action 导致新的 actions 被触发, 状态就未必一致了.

整体考虑为了热替换方便, 组件局部状态的变化, 是不鼓励的.
目前 Respo Effects 算是出现在早期状态, 后面也可能再调整.

其他

不管怎样, 此前 Respo 为了实现纯的渲染, 没有做 effects,
导致跟 JavaScript 生态已有的一些用法不能轻松衔接.
现在加上了 Effects, 那些东西终于可以进行尝试了.

Respo 最初版本是 2016 年初开始的, 年中基本完成,
这么多年了, 用的人少, 这方面的需求也没有太大的问题, 因为场景也有限.
我个人觉得 Effects 不会有太多的需要. 还是以小范围扩展功能位置.

查看原文

赞 2 收藏 0 评论 0

题叶 发布了文章 · 2019-10-22

[小组分享] React 当中性能优化手段整理

内部小组分享底稿.

回顾一下 React

  • class 组件的优化
  • useMemo 提供的优化
  • React.memo 优化
  • useCallback 优化
  • 避免 render 当中的 DOM 操作

class 组件的优化

通过判断减少数据变化触发的重新渲染, 以及之后的 DOM diff

shouldComponentUpdate(nextProps, nextState) {
  if (this.props.color !== nextProps.color) {
    return true;
  }
  if (this.state.count !== nextState.count) {
    return true;
  }
  return false;
}

JavaScript 对象引用问题

函数式语言当中, 语言设计允许两个对象一样, 举例 Clojure:

(= {:a 1} {:a 1}) ; true

(identical? {:a 1} {:a 1}) ; false

递归匹配, 性能并不高.

JavaScript 对象基于引用传值, 比较单一

{a: 1} === {a: 1} // false

大体方案, 通过手动维护, 让相同的数据尽量保证引用一致, 控制性能.

function updateColorMap(colormap) {
  return {...colormap, right: 'blue'};
}

useMemo 优化

每个函数体当中生成的对象都会有新的引用, useMemo 可以保留一致的引用.

const myObject = useMemo(() => ({ key: "value" }), [])

注意: 用花括号直接写对象基本上就是新的引用了,

{}
{a: 1}

{...obj}

一般组件内部不变的对象, 都是从 state, ref, 再或者组件外全局有一个引用.


React.memo 优化

判断参数是否改变, 如果没有改变, 就直接复用已有的组件, 不重新生成:

const MyComponent = React.memo(function MyComponent(props) {
  /* only rerenders if props change */
});

React.memo 有第二个参数, 用于自定义判断的规则:

const MemoItem = React.memo(Item, (prevProps, nextProps) => {
  if (prevProps.item.selected === nextProps.item.selected) {
    return true;
  }
  return false;
});

useCallback 优化

使用 React.memo 包裹组件:

let Inner: FC<{
  onClick: () => void
}> = React.memo((props) => {
  return <div>
    <span>inner</span>
    </div>;
});

使用 useCallback

let Outer: FC<{}> = React.memo((props) => {
  const [counter, setCounter] = useState(0);
  const onClick = useCallback(()=>{ 
    setCounter(prevState => ++prevState)
  },[]);
  return <div>
    <span>outer: {counter}</span>
    <Inner onClick={onClick} />
  </div>;
});

避免 render 当中的 DOM 操作

let NewComponent: FC<{}> = React.memo((props) => {

  let elRef = useRef<HTMLDivElement>()

  // 错误写法
  if (elRef.current) {
    elRef.current.style.color = 'red'
  }

  return <div ref={elRef}></div>;
});

DOM 发生改变的时候, 一般会有比较多后续的布局和 compose 计算去绘制新的界面.

特别是在脚本执行过程当中发生的话, 会对性能有明显影响.

脚本执行完再执行, 让浏览器自动处理(合并, 避免频繁 DOM 操作).


业务相关

  • immer 对优化方案的影响
  • Rex 组件当中优化的坑
  • 路由相关的优化
  • 性能调试

Immer 对优化方案的影响

let a = {}
let b = produce(a, draft => {
  draft.b = 1
})

a === b // false

如果数据不发生改变, 直接用原始数据.

(Hooks API 之后, 数据被拆散了, 可以减少 immer 的使用.)


Rex 当中优化的相关

class 组件, 高阶组件当中自动做了基础的优化.

shouldComponentUpdate(nextProps: IRexDataLayerProps, nextState: any) {
  if (!shallowequal(nextProps.parentProps, this.props.parentProps)) return true;
  if (!shallowequal(nextProps.computedProps, this.props.computedProps)) return true;
  return false;
}

Hook API, 没有中间一层组件, 直接触发当前组件更新, 存在性能问题.(还要考虑优化方案)

let contextData = useRexContext((store: IGlobalStore) => {
  return {
    data: store.data,
    homeData: store.homeData,
  };
});

业务当中一般可以接受, 因为数据通常都是在更新的. 新能敏感场景需要额外考虑.


ruled-router 提供的优化

/home/plant/123/shop/456/789

解析为

{
  "raw": "home",
  "name": "home",
  "matches": true,
  "restPath": ["plant", "123", "shop", "456", "789"],
  "params": {},
  "data": {},
  "next": {
    "raw": "plant/:plantId",
    "name": "plant",
    "matches": true,
    "restPath": ["shop", "456", "789"],
    "params": {
      "plantId": "123"
    },
    "data": {
      "plantId": "123"
    },
    "next": {
      "raw": "shop/:shopId/:corner",
      "name": "shop",
      "matches": true,
      "next": null,
      "restPath": [],
      "data": {
        "shopId": "456",
        "corner": "789"
      },
      "params": {
        "plantId": "123",
        "shopId": "456",
        "corner": "789"
      }
    }
  }
}

生成对象保存起来, 路由发生变更时再重新解析. 这样对象引用一般保持一致.


性能优调试

DevTools

https://developers.google.com...

React DevTools

https://www.debugbear.com/blo...


其他

官方推荐性能优化方案...

https://reactjs.org/docs/opti...


实际遇到

树形组件: 隐藏子树, 定制减少更新. (个人建议看情况自己实现, 通用组件一般都不好优化).

useMemo

Dropdown 的替换, 老版本 antd 的 bug(升级 rc-select@9.0.3).

https://github.com/react-comp...


需要优化

  • form
  • table
  • ...

THX. QA.


其他关于积梦前端的模块和工具可以查看我们的 GitHub 主页 https://github.com/jimengio .
目前团队正在扩充, 招聘文档见 GitHub 仓库 https://github.com/jimengio/h... .

查看原文

赞 12 收藏 7 评论 0

题叶 发布了文章 · 2019-10-11

TypeScript 用 Webpack/ts-node 运行的配置记录

公司项目代码是用 TypeScript 写的, 中间遇到有些代码不要放到 Node 里面去跑.
具体场景一些路由配置, 比较大的一块 JSON 数据定义在 TypeScript 里.
我另外有增加脚本, 基于这些 JSON 数据用来生成切换路由的函数.
这就需要运行 TypeScript 了, 而且可能包含一些额外的业务代码.

首先 Node 运行 TypeScript 有提供 ts-node 用来处理.
ts-node 会先编译 TypeScript 代码到 JavaScript, 再调用 Node 运行.
不过这个办法有一些问题, 一个是 TypeScript 定义的路径配置不成功,
另一个问题更麻烦点, 就是引用到的其他的浏览器端代码因为触发运行而引起报错.

Webpack 打包 TypeScript Node 代码

我先想到了一个相对省事的方案, 就是用 Webpack 对 TypeScript 进行打包.
打包完成以后输出 JavaScript 代码. 而浏览器代码打包进去, 但不一定运行.
由于 TypeScript 配置在 Webpack 当中引用有比较成熟的方案, 整个配置也写好:

module.exports = {
  mode: "development",
  target: "node",
  entry: ["./example/gen-router.ts"],
  output: {
    filename: "gen-router.js",
    path: path.join(__dirname, "../", distFolder),
  },
  devtool: "none",
  module: {
    rules: [
      // 正常的 TypeScript 编译方式, 我这份是拷贝的.
      {
        test: /\.tsx?$/,
        exclude: [/node_modules/, path.join(__dirname, "scripts")],
        use: [
          { loader: "cache-loader" },
          {
            loader: "thread-loader",
            options: {
              workers: require("os").cpus().length - 1,
            },
          },
          {
            loader: "ts-loader",
            options: {
              happyPackMode: true,
              transpileOnly: true,
            },
          },
        ],
      },
    ],
  },
  // Node 模块, 写在 external 里面表明不需要进行打包. 注意 commonjs 前缀
  externals: {
    prettier: "commonjs prettier",
    "@jimengio/router-code-generator": "commonjs @jimengio/router-code-generator",
    fs: "commonjs fs",
    path: "commonjs path",
  },
  resolve: {
    extensions: [".tsx", ".ts", ".js"],
    modules: [path.join(__dirname, "example"), "node_modules"],

    // 引用 Plugin 用于读取 tsconfig.json 文件的配置
    plugins: [new TsconfigPathsPlugin({ configFile: path.join(__dirname, "../tsconfig.json") })],
  },
};

基于这个配置打包以后, TypeScript 的代码被打包好, 并且引用响应的 Node 模块.
运行就满足需求了.

这个方式对于其他的服务端渲染的 TypeScript 代码打包也是类似的.
一些特殊的依赖如果不好处理, 可以放在 Webpack 当中进行打包和映射, 得到 js.

ts-node 运行

Webpack 配置相对直接运行 TypeScript 来说会复杂一点, 所以还是 ts-node 简单.
在依赖少的项目当中, 我改成了用 ts-node 来进行编译运行. 配置如下

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "sourceMap": true,
    "noImplicitAny": false,
    "noImplicitThis": true,
    "strictNullChecks": false,
    "moduleResolution": "node",

    // Node 当前还没有支持直接运行 import/export 语法, 需要编译到 CommonJS
    "module": "commonjs",

    "target": "es2016",
    "jsx": "react",
    "lib": ["es2016"],
    "types": ["node"],
    "baseUrl": "./example/",
    "paths": {
      "models": ["./example/models"]
    },
    "plugins": []
  }
}

其实主要修改就 commonjs 那一行, 然后就是加上参数运行

ts-node -P tsconfig-node.json -r tsconfig-paths/register example/gen-router.ts

注意命令当中的 tsconfig-paths. 这里的 -r 是指定 register.
ts-node 是先进行编译再运行的, 但是引用的路径没有全都替换掉.
比如我在 tsconfig.json 里设置了 baseUrl 然后内部引用是简写的, a/b/c,
拿到 Node 本身去运行的时候是不知道这个 a/b/c 对应到哪里,
所以 tsconfig-paths/register 就提供 Node 运行时的方案, 动态查找依赖.
至少这样 Node register 改写以后, 查找模块就能正确进行了.

其他

另外 TypeScript 编译 import 语法时会产生一个 .default 属性.
对于 CommonJS 的模块, 这个 .default 属性是多余的. 所以引用的写法要做调整.

import * as fs from "fs";
import * as path from "path";
import * as prettier from "prettier";

这个可能跟 tsconfig.json 里其他的配置有关联, 我没继续深挖.

整体的代码参考 https://github.com/jimengio/meson-form/pull/62/files


其他关于积梦前端的模块和工具可以查看我们的 GitHub 主页 https://github.com/jimengio .
目前团队正在扩充, 招聘文档见 GitHub 仓库 https://github.com/jimengio/h... .

查看原文

赞 1 收藏 0 评论 0

题叶 赞了文章 · 2019-10-10

使用Nightwatch进行E2E测试中文教程

E2E测试

E2E(end to end)测试是指端到端测试又叫功能测试,站在用户视角,使用各种功能、各种交互,是用户的真实使用场景的仿真。在产品高速迭代的现在,有个自动化测试,是重构、迭代的重要保障。对web前端来说,主要的测试就是,表单、动画、页面跳转、dom渲染、Ajax等是否按照期望。

E2E测试驱动重构

重构代码的目的是什么?是为了使代码质量更高、性能更好、可读性和拓展性更强。在重构时如何保证修改后正常功能不受影响?E2E测试正是保证功能的最高层测试,不关注代码实现细节,专注于代码能否实现对应的功能,相比于单元测试、集成测试更灵活,你可以彻底改变编码的语法、架构甚至编程范式而不用重新写测试用例。

Nightwatch

知道nightwatch是因为vue-cli工具安装的时候会询问是否需要安装nightwatch。本身vue项目也是使用nightwatch来e2e测试的。nightwatch是一个使用selenium或者webdriver或者phantomjs的nodejs编写的e2e自动测试框架,可以很方便的写出测试用例来模仿用户的操作来自动验证功能的实现。selenium是一个强大浏览器测试平台,支持firefox、chrome、edge等浏览器的模拟测试,其原理是打开浏览器时,把自己的JavaScript文件嵌入网页中。然后selenium的网页通过frame嵌入目标网页。这样,就可以使用selenium的JavaScript对象来控制目标网页。

Nightwatch安装

通过npm安装nightwatch。

$ npm install [-g] nightwatch

根据需要安装Selenium-server或者其他Webdriver,比手动去下载jar文件要方便很多。安装哪些Webdriver取决于你想要测试哪些浏览器,如果只测试Chrome甚至可以不装Selenium-server

$ npm install selenium-server
$ npm install chromedriver

Nightwatch的配置

nightwatch的使用很简单,一个nightwatch.json或者nightwatch.config.js(后者优先级高)配置文件,使用runner会自动找同级的这两个文件来获取配置信息。也可以手动使用--config来制定配置文件的相对路径。

{
  "src_folders" : ["tests"],
  "output_folder" : "reports",
  "custom_commands_path" : "",
  "custom_assertions_path" : "",
  "page_objects_path" : "",
  "globals_path" : "",

  "selenium" : {
    "start_process" : false,
    "server_path" : "",
    "log_path" : "",
    "port" : 4444,
    "cli_args" : {
      "webdriver.chrome.driver" : "",
      "webdriver.gecko.driver" : "",
      "webdriver.edge.driver" : ""
    }
  },

  "test_settings" : {
    "default" : {
      "launch_url" : "http://localhost",
      "selenium_port"  : 4444,
      "selenium_host"  : "localhost",
      "silent": true,
      "screenshots" : {
        "enabled" : false,
        "path" : ""
      },
      "desiredCapabilities": {
        "browserName": "firefox",
        "marionette": true
      }
    },

    "chrome" : {
      "desiredCapabilities": {
        "browserName": "chrome"
      }
    },

    "edge" : {
      "desiredCapabilities": {
        "browserName": "MicrosoftEdge"
      }
    }
  }
}

json配置文件大概就是上面这样,分为基本配置、selenium配置和测试配置三个部分。基本配置依次为测试用例源文件路径、输出路径、基础指令路径、全局配置路径等。selenium设置包括是否开启、路径、端口等,cli_args指定将要运行的webdriver。test_settings制定测试时各个环境的设置,默认是default,通过--env加环境名可以指定配置的任意环境。只要把测试用例放在对应的文件夹使用module.exports暴露一个对象,其中key是测试名,value是一个接受browser实例的函数,在函数中进行断言,nightwatch会自动依次调用文件夹中的测试用例。一个简易的Chrome headless模式的nightwatch.conf.js配置如下:

{
    'src_folders': ['test/e2e/specs'],
    'output_folder': 'test/e2e/reports',
    'globals_path': 'test/e2e/global.js',
    'selenium': {
        'start_process': true,
        'server_path': require('selenium-server').path,
        'port': port,
        'cli_args': {
            'webdriver.chrome.driver': require('chromedriver').path
        }
    },

    'test_settings': {
        'default': {
            'selenium_port': port,
            'selenium_host': 'localhost',
            'silent': true,
            'globals': {
                'productListUrl': 'http://localhost:' + 9003 + '/productlist.html',
            }
        },

        'chrome': {
            'desiredCapabilities': {
                'browserName': 'chrome',
                'javascriptEnabled': true,
                'acceptSslCerts': true,
                'chromeOptions': {
                    'args': [
                       '--headless',
                     '--disable-gpu'
                    ],
                    'binary': '/opt/google/chrome/google-chrome'
                }
            }
        },

        'globals': {
            'productListUrl': 'http://localhost:' + 9003 + '/productlist.html',
        }
    }
}

API

Nightwatch的API分为四个部分

1.Expect

在browser实例上以.expect.element开头的BDD(行为驱动测试)风格的接口,0.7及以上版本nightwatch可用。通过.element方法传入一个selector(参考querySelector或者jq的语法)获取到dom实例,通过.text、.value、.attribute等方法获取到实例属性。还有一些语意明确的修饰:

  • to
  • be
  • been
  • is
  • that
  • which
  • and
  • has
  • with
  • at
  • does
  • of

再加上比较判断:

.equal(value)/.contain(value)/.match(regex)

.selected

.present

还有时间修饰.before(ms)(表示一段时间之内)、.after(ms)(表示一段时间之后)。就像造句一样:某某元素的某某属性(在某某时间)(不)等于什么值,这就是BDD风格的测试代码。例如:

this.demoTest = function (browser) {
      browser.expect.element('body').to.have.attribute('data-attr');
      browser.expect.element('body').to.not.have.attribute('data-attr');
      browser.expect.element('body').to.not.have.attribute('data-attr', 'Testing if body does not have data-attr');
      browser.expect.element('body').to.have.attribute('data-attr').before(100);
      browser.expect.element('body').to.have.attribute('data-attr')
    .equals('some attribute');
      browser.expect.element('body').to.have.attribute('data-attr')
    .not.equals('other attribute');
      browser.expect.element('body').to.have.attribute('data-attr')
    .which.contains('something');
      browser.expect.element('body').to.have.attribute('data-attr')
    .which.matches(/^something\ else/);
};

2.Assert

以.assert/.verify开头的两套相同的方法库,区别是assert如果断言失败则退出整个测试用例所有步,verify则打印后继续进行。

this.demoTest = function (browser) {
      browser.verify.title("Nightwatch.js");
      browser.assert.title("Nightwatch.js");
};

有如下判断方法:

.attributeContains(selector, attribute, expected[, message])
检查指定元素(selector)的指定属性(attribute)是否包含有期待的值(expected)打印出指定信息(可选填的message)其他方法讲解类似,不一一赘述

.attributeEquals(selector, attribute, expected[, message])
检查元素指定属性是否等于预期

.containText(selector, expectedText[, message])
包含有指定的文本

.cssClassPresent(selector, className[, message])
检查元素指定class是否存在

.cssClassNotPresent(selector, className[, message])
检查元素指定class是否不存在

.cssProperty(selector, cssProperty, expected[, message])
检查元素指定css属性的值是否等于预期

.elementPresent(selector[, message)
检查指定元素是否存在于DOM中

.elementNotPresent(selector[, message)
检查指定元素是否不存在于DOM中

.hidden(selector[, message)
检查指定元素是否不可见

.title(expected[, message])
检查页面标题是否等于预期

.urlContains(expectedText[, message])
检查当前URL是否包含预期的值

.urlEquals(expected[, message])
检查当前URL是否等于预期的值

.value(selector, expectedText[, message])
检查指定元素的value是否等于预期

.valueContains(selector, expectedText[, message])
检查指定元素的value是否包含预期的值

.visible(selector[, message)
检查指定元素是否可见

3.Commands

很多命令的读写,可以操作BOM、DOM对象:

.clearValue(selector[, message])
清空input、textarea的值

.click(selector[, callback])
callback为执行完命令后需要执行的回调

.closeWindow([callback])

.deleteCookie(cookieName[, callback])

.deleteCookies([callback])

.end([callback])
结束会话(关闭窗口)

.getAttribute(selector, attribute, callback)

.getCookie(cookieName, callback)

.getCookies(callback)

.getCssProperty(selector, cssProperty, callback)

.getElementSize(selector, callback)

.getLocation(selector, callback)

.getLocationInView(selector, callback)

.getLog(typeString, callback)
获取selenium的log,其中type为string或者function

.getLogTypes(callback)

.getTagName(selector, callback)

.getText(selector, callback)

.getTitle(callback)

.getValue(selector, callback)

.init([url])
url方法的别名,如果不传url则跳转到配置中的launch_url

.injectScript(scriptUrl[, id, callback])
注入script

.isLogAvailable(typeString, callback)
typeString为string或者function,用来测试log的type是否可用

.isVisible(selector, callback)

.maximizeWindow([callback])
最大化当前窗口

.moveToElement(selector, xoffset, yoffset[, callback])
移动鼠标到相对于指定元素的指定位置

.pause(ms[, callback])
暂停指定的时间,如果没有时间,则无限暂停

.perform(callback)
一个简单的命令,允许在回调中访问api

.resizeWindow(width, height[, callback])
调整窗口的尺寸

.saveScreenshot(fileName, callback)

.setCookie(cookie[, callback])

.setValue(selector, inputValue[, callback])

.setWindowPosition(offsetX, offsetY[, callback])

.submitForm(selector[, callback])

.switchWindow(handleOrName[, callback])

.urlHash(hash)

.useCss()
设置当前选择器模式为CSS

.useXpath()
设置当前选择器模式为Xpath

.waitForElementNotPresent(selector, time[, abortOnFailure, callback, message])
指定元素指定时间内是否不存在

.waitForElementNotVisible(selector, time[, abortOnFailure, callback, message])
指定元素指定时间内是否不可见

.waitForElementPresent(selector, time[, abortOnFailure, callback, message])

.waitForElementVisible(selector, time[, abortOnFailure, callback, message])

简单的例子:

this.demoTest = function (browser) {
    browser.click("#main ul li a.first", function(response) {
    this.assert.ok(browser === this, "Check if the context is right.");
    this.assert.ok(typeof response == "object", "We got a response object.");
    });
};

4.webdriver protocol

可以操作一些更底层的东西,比如:

  • Sessions
  • Navigation
  • Command Contexts
  • Elements
  • Element State
  • Element Interaction
  • Element Location
  • Document Handling
  • Cookies
  • User Actions
  • User Prompts
  • Screen Capture
  • Mobile Related

简单的例子:

module.exports = {
 'demo Test' : function(browser) {
    browser.element('css selector', 'body', function(res) {
      console.log(res)
    });
  }
};

拓展

也可以单独使用chromedriver等进行单一平台测试,效率更高,测试更快。只需要npm安装chromedriver或者其他webdriver,不需要selenium,在selenium设置中把selenium进程设置为false,测试环境配置中做出相应的改变。在golobal_path设置的配置文件中,利用nightwatch测试的全局before和after钩子中开、关服务器就好:

var chromedriver = require('chromedriver');

function startChromeDriver() {
  chromedriver.start();
}

function stopChromeDriver() {
  chromedriver.stop();
}

module.exports = {
  before : function(done) {
    startChromeDriver.call(this);
    done();
  },

  after : function(done) {
    stopChromeDriver.call(this);
    done();
  }
};

配置尤雨溪大神的nightwatch-helpers食用更佳,补了一些api。Assertions:

  • count(selector, count)
  • attributePresent(selector, attr)
  • evaluate(fn, [args], [message])
  • checked(selector, expected)
  • focused(selector, expected)
  • hasHTML(selector, html)
  • notVisible(selector)

Commands:

  • dblClick(selector)
  • waitFor(duration)
  • trigger(selector, event[, keyCode])
  • enterValue(selector, value)

只需要在图中位置配置一下即可image.png

其他

推荐使用Headless测试即不打开浏览器可视界面以便能跑在服务器上。比如Phantomjs可以模拟webkit内核浏览器的行为,在Nightwatch中配置一下Phantomjs环境即可,启动nightwatch时使用--env加上配置里的环境名激活对应的环境。如今(59版本以上)Phantomjs已经停止维护,使用Chrome自带的headless模式是更好的选择。也可以使用Puppeteer来做E2E测试,好处是只依赖一个Puppeteer,并且API相对简单。

欢迎来我博客

查看原文

赞 3 收藏 2 评论 0

题叶 赞了文章 · 2019-10-10

Puppeteer前端自动化测试实践

本篇内容将记录并介绍使用Puppeteer进行自动化网页测试,并依靠约定来避免反复修改测试用例的方案。主要解决页面众多时,修改代码导致的牵连错误无法被发现的运行时问题。文章首发于个人博客
对前端感兴趣希望一起讨论的可以加我vx:w554091944

起因

目前我们在持续开发着一个几十个页面,十万+行代码的项目,随着产品的更迭,总会出现这样的问题。在对某些业务逻辑或者功能进行添加或者修改的时候(尤其是通用逻辑),这些通用的逻辑或者组件往往会牵扯到一些其他地方的问题。由于测试人员受限,我们很难在完成一个模块单元后,对所有功能重新测试一遍。
同时,由于环境及数据的区别,(以及在开发过程中对代码完备性的疏忽),代码会在某些特殊数据的解析和和展示上出现问题,在开发和测试中很难去发现。总的来说,我们希望有一个这样的工具,帮我们解决上述几个问题:

  1. 在进行代码和功能改动后,能够自动访问各个功能的页面,检测问题
  2. 针对大量的数据内容,进行批量访问,检测对于不同数据的展示是否存在问题
  3. 测试与代码功能尽量不耦合,避免每次上新功能都需要对测试用例进行修改,维护成本太大
  4. 定期的测试任务,及时发现数据平台针对新数据的展示完备性

其中,最重要的问题,就是将测试代码与功能解耦,避免每次迭代和修改都需要追加新的测试用例。我们如何做到这一点呢?首先我们来梳理下测试平台的功能。

功能设定

由于我们的平台主要是进行数据展示,所以我们在测试过程中,主要以日常的展示数据为重心即可,针对一些复杂的表单操作先不予处理。针对上述的几个问题,我们针对自动化测试工具的功能如下:

  1. 依次访问各个页面
  2. 访问各个页面的具体内容,如时间切换、选项卡切换、分页切换、表格展开行等等
  3. 针对数据表格中的详情链接,选择前100条进行访问,并进行下钻页的继续测试
  4. 捕获在页面中的错误请求
  5. 对错误信息进行捕获,统计和上报

根据以上的梳理,我们可以把整个应用分为几个测试单元

  • 页面单元,检测各功能页面访问的稳定性
  • 详情页单元,根据页面的数据列表,进行批量的详情页跳转,检测不同参数下详情页的稳定性
  • 功能单元,用于检测页面和详情页各种展示类型点击切换后是否产生错误

图片描述

通过这样的划分,我们针对各个单元进行具体的测试逻辑书写用例,这样就可以避免再添加新功能和页面时,频繁对测试用例进行修改了。

Puppeteer

带着上面我们的需求,我们来看下Puppeteer的功能和特性,是否能够满足我们的要求。

文档地址

Puppeteer是一个Node库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。

我们可以使用Puppeteer完成以下工作:

  • 访问页面,进行截图
  • 自动进行键盘输入,提交表单
  • 模拟点击等用户操作
  • 等等等等。。

我们来通过一些小案例,来介绍他们的基本功能:

访问一个带有ba认证的网站

puppeteer可以创建page实例,并使用goto方法进行页面访问,page包含一系列方法,可以对页面进行各种操作。

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  // ba认证
  await page.authenticate({
    username,
    password
  });
  // 访问页面
  await page.goto('https://example.com');
  // 进行截图
  await page.screenshot({path: 'example.png'});

  await browser.close();
})();

访问登陆页面,并进行登录

首先,对于SPA(单页面应用),我们都知道,当页面进入后,客户端代码才开始进行渲染工作。我们需要等到页面内容渲染完成后,再进行对应的操作。我们有以下几种方法来使用

waitUntil

puppeteer针对页面的访问,切换等,提供了waitUntil参数,来确定满足什么条件才认为页面跳转完成。包括以下事件:

  • load - 页面的load事件触发时
  • domcontentloaded - 页面的DOMContentLoaded事件触发时
  • networkidle0 - 不再有网络连接时触发(至少500毫秒后)
  • networkidle2 - 只有2个网络连接时触发(至少500毫秒后)

通过waitUnitl,我们可以当页面请求都完成之后,确定页面已经访问完成。

waitFor

waitFor方法可以在指定动作完成后才进行resolve

// wait for selector
await page.waitFor('.foo');
// wait for 1 second
await page.waitFor(1000);
// wait for predicate
await page.waitFor(() => !!document.querySelector('.foo'));

我们可以利用waitForSelector方法,当登录框渲染成功后,才进行登录操作

// 等待密码输入框渲染
await page.waitFor('#password');
// 输入用户名
await page.type('input#username', "username");
// 输入密码
await page.type('input#password', "testpass");

// 点击登录按钮
await Promise.all([
  page.waitForNavigation(), // 等跳转完成后resolve
  page.click('button.login-button'), // 点击该链接将间接导致导航(跳转)
]);

await page.waitFor(2000)

// 获取cookies
const cookies = await page.cookies()

针对列表内容里的链接进行批量访问

主要利用到page实例的选择器功能

const table = await page.$('.table')
const links = await table.$$eval('a.link-detail', links =>
  links.map(link => link.href)
);

// 循环访问links
...

进行错误和访问监听

puppeteer可以监听在页面访问过程中的报错,请求等等,这样我们就可以捕获到页面的访问错误并进行上报啦,这也是我们进行测试需要的基本功能~

// 当发生页面js代码没有捕获的异常时触发。
page.on('pagerror', () => {})
// 当页面崩溃时触发。
page.on('error', () => {})
// 当页面发送一个请求时触发
page.on('request')
// 当页面的某个请求接收到对应的 response 时触发。
page.on('response')

通过以上的几个小案例,我们发现Puppeteer的功能非常强大,完全能够满足我们以上的对页面进行自动访问的需求。接下来,我们针对我们的测试单元进行个单元用例的书写

最终功能

通过我们上面对测试单元的规划,我们可以规划一下我们的测试路径

访问网站 -> 登陆 -> 访问页面1 -> 进行基本单元测试 -> 获取详情页跳转链接 -> 依次访问详情页 -> 进行基本单元测试

-> 访问页面2 ...

所以,我们可以拆分出几个大类,和几个测试单元,来进行各项测试

// 包含基本的测试方法,log输出等
class Base {}

// 详情页单元,进行一些基本的单元测试
class PageDetal extends Base {}

// 页面单元,进行基本的单元测试,并获取并依次访问详情页
class Page extends PageDetal {}

// 进行登录等操作,并依次访问页面单元进行测试
class Root extends Base {}

同时,我们如何在功能页面变化时,跟踪到测试的变化呢,我们可以针对我们测试的功能,为其添加自定义标签test-role,测试时,根据自定义标签进行测试逻辑的编写。

例如针对时间切换单元,我们做一下简单的介绍:

// 1. 获取测试单元的元素
const timeSwitch = await page.$('[test-role="time-switch"]');

// 若页面没有timeSwitch, 则不用进行测试
if (!timeSwitch) return

// 2. time switch的切换按钮
const buttons = timeSwitch.$$('.time-switch-button')

// 3. 对按钮进行循环点击
for (let i = 0; i < buttons.length; i++) {
  const button = buttons[i]

  // 点击按钮
  await button.click()

  // 重点! 等待对应的内容出现时,才认定页面访问成功
  try {
    await page.waitFor('[test-role="time-switch-content"]')
  } catch (error) {
    reportError (error)
  }

  // 截图
  await page.screenshot()
}

上面只是进行了一个简单的访问内容测试,我们可以根据我们的用例单元书写各自的测试逻辑,在我们日常开发时,只需要对需要测试的内容,加上对应的test-role即可。

总结

根据以上的功能划分,我们很好的将一整个应用拆分成各个测试单元进行单元测试。需要注意的是,我们目前仅仅是对页面的可访问性进行测试,仅仅验证当用户进行各种操作,访问各个页面单元时页面是否会出错。并没有对页面的具体展示效果进行测试,这样会和页面的功能内容耦合起来,就需要单独的测试用例的编写了。

查看原文

赞 36 收藏 23 评论 4

题叶 发布了文章 · 2019-09-26

font-carrier 生成字体操作记录

主要是公司内部的 SVG 制作的图标, 提供网页当中使用的字体.
代码仓库在 https://github.com/jimengio/j...

字体生成步骤

从 SVG 文件到可以给前端用的 npm 模块, 主要经过:

  • SVG 文件使用第三方模块生成字体文件(ttf, svg, woff...)
  • 生成图标跟 CSS 的映射
  • 生成 TypeScript 组件当中使用枚举类型
  • 打包更新 npm 模块

生成字体文件跟 CSS 映射我们之前用的是 webfonts-generator,
不过这个模块已经停止维护了, npm 上的版本还是有 bug 的, 主分支的还好一点.
考虑到使用不方便, 一致在寻找替代方案. 在一丝的指导下切换到了 font-carrier.

font-carrier 可以生成字体, 以及基础的 CSS 文件.
不过跟 webfonts-generator 不一样的是生成的 CSS 不要 class 来区分图标,
而是用 HTML 当中 utf8 字符直接跟图标字体对应... 当然原理跟以前一样..

<i
  dangerouslySetInnerHTML={{ __html: `&#${fontsDict[props.name]}` || `NONE:${props.name}` }}
></i>

生成字体文件的代码就是调用 font-carrier 的 API,
另外自己记录了一个 dict JSON 对象, 用来存储码表...

initialFontValue = 0xe000

String.fromCharCode(initialFontValue)

fonts = fontCarrier.create()

dict = {}

icons.forEach (icon) ->
  initialFontValue += 1
  char = String.fromCharCode initialFontValue
  fonts.setSvg(char, fs.readFileSync("./svg/#{icon}.svg").toString())
  dict[icon] = initialFontValue

fonts.output
  path: './src/fonts/jimo'

然后主要是生成类型文件的工作.. 基于 dict 数据生成 enum, 基本够用.

遇到的坑

使用 font-carrier 过程当中有遇到一些问题, 联系维护者解决掉了,

  • 图标形状奇怪

遇到一个圆弧反向的问题, 本来不知道怎么下手, 用 Glyphs 看了 TTF 文件的线条,,
注意到所有的线条跟原先的 SVG 在 Sketch 里面刚好反向了, 就怀疑是转的问题,
SVG 的 arc 圆弧有一个 SWEEP 值, 表示圆弧的方向, 就觉得是这个用错了.
维护者排查了一下, 是已知的问题, 在 svgpath 模块当中已经解决:
https://github.com/fontello/s...
最后靠升级依赖的版本解决掉了, 按说后续不会再遇到.

  • 图标居中出现问题

图标对应的是 UTF8 的字符, 最开始我选择的数值比较小, 主要跳过常用的码位.
不过当时出现了问题, 就是图标左右居中不正常, 有很多明显往左边偏移...
排查以后原因是选择的码位范围有问题, 刚好命中了一些奇怪的字符...
维护者推荐的码位范围是 0xe000 以上, 我估计对应的是空白的 UTF8 码位. 解决了.

  • 曲线闭合问题

从接手项目的时候就提到了 SVG 的图标需要曲线闭合,
不是完全懂什么意思, 估计是路径要求闭合, 方便字体填充颜色之类的.
设计师没处理好的话, 图标是显示空白的. 用 https://iconfont.cn 可以验证.

其他

另外一丝推荐的方案是我们设计师直接用 https://www.iconfont.cn 维护.
工具上可以提供 CSS 还有码位的导出, 比起我们手工处理要省事一点.
没想清楚, 后续如果有契机而且设计师可以维护的话, 考虑是否迁移过去.


其他关于积梦前端的模块和工具可以查看我们的 GitHub 主页 https://github.com/jimengio .
目前团队正在扩充, 招聘文档见 GitHub 仓库 https://github.com/jimengio/h... .

查看原文

赞 1 收藏 0 评论 1

题叶 发布了文章 · 2019-09-26

Linaria 替换 Emotion 操作记录

由于 linaria 已经没有活跃维护了, 文章里的方案不推荐使用.

EmotionLinaria 是两个 CSS in JS 方案, API 相近.
项目里有特殊的场景, 希望能减小体积, 我们一贯基于 Emotion 比较大,

clipboard.png

我们的 Emotion 现在都是跟着 JavaScript 走到, 没有做 CSS 分离,
之前尝试过生产环境分离 CSS, 但是因为 CSS 规则顺序问题, 效果不够可控,
加上不想在 TypeScript 后面套一层 Babel, 这条路也就不想走了.
所以目前打包的 JavaScript 里面就有图片上这么大的 Emotion 代码的体积.

Linaria

justineo 提醒我说 Emotion 可以用 Linaria 替换, 我就去看了一下.
这个库的 API 基本上跟 Emotion 一致, 我们的写法大致用到了,

import {css, cx} from "emotion"

let styleA = css`
  color: red;
`;

在 Linaria 当中基本上无缝替换了,

import {css, cx} from "linaria"

let styleA = css`
  color: red;
`;

另外我关注的 prefix vendor, 在 linaria 里边一样也是有支持的.
其他的 API 应该也出跟着 style-components 的方案一致的.

跟 Emotion 相比, linaria 有个比较完善的静态的 css 分离的功能.

https://callstack.com/blog/ho...

In short, CSS in JS libraries such as Emotion and Styled Components parse and apply your styles when your page loads in the browser, Linaria extracts the styles to CSS files when you build your project (e.g. with webpack), and the CSS files are loaded normally.

Emotion used to have a static extraction mode, which was limited in the sense that it doesn’t support interpolations in the CSS string and can’t use many of Emotion’s features. Linaria will evaluate the interpolations such as JavaScript variables and functions in the CSS string at build time.

推测是去掉了 Emotion 某些动态的特性的支持把, 方便分离 CSS.
分离的过程是通过 Babel 完成的, 所以在特殊的场景当中我还是需要 Babel.

相关配置

这个修改增加了几个相关依赖,

    "babel-loader": "^8.0.6",
    "linaria": "^1.3.1",
    "core-js": "^2.6.5",
    "string-replace-loader": "^2.2.0",

core-js 需要锁定版本, 过高的版本因为不兼容是出现了报错的.
string-replace 是为了处理依赖当中引用了 Emotion 的代码.
项目当中的代码我可以手动更改, 但是依赖组件因为其他项目复用, 不好直接改掉,
通过 Webpack 增加配置, 把 Emotion 的依赖都指向 Linaria(不一定需要):

  resolve: {
    alias: {
      emotion: "linaria"
    }
  },

然后考虑到依赖代码引用得早, 还是要通过字符串替换把已有的引用提早替换掉:

  test: /\.js?$/,
  use: [
    {
      loader: "string-replace-loader",
      options: {
        search: `"emotion"`,
        replace: `"linaria"`
      }
    }
  ]

其他的部分主要靠 Linaria 自己的工具配合 Babel 去搞了.

loader: require.resolve("linaria/loader")

我这边 CSS 最终没有分离出去, 因为需求方面要打成一个 js 的包, 所以还是用 style 标签运行.
打包以后带着 style-loadercss-loader 的代码会显得比较大一点,

clipboard.png

另外也要增加配置让 Webpack 去掉 Node 相关的代码, 不需要打包进来:

node: false,

而 CSS 部分的代码, 被 Linaria 处理, 单独以 CSS 文件的形式存在了.

clipboard.png

道理讲应该是还可以去掉 style-loader 等等, 直接把 CSS 引入标签当中,
没想好建构方面怎么处理, 暂时先不管了.

其他

整体处理下来, Emotion 换上了 style-loader 等, 体积减小 20k,
压缩以后大致上也就是 2k 多一些, 跟预想的还是可以的.
但是如果能有办法把 style-loader 部分也简化掉, 还能减小一些, 再找找建构方案...


其他关于积梦前端的模块和工具可以查看我们的 GitHub 主页 https://github.com/jimengio .
目前团队正在扩充, 招聘文档见 GitHub 仓库 https://github.com/jimengio/h... .

查看原文

赞 2 收藏 1 评论 0