kpaxqin

kpaxqin 查看完整档案

成都编辑  |  填写毕业院校  |  填写所在公司/组织 kpaxqin.github.io 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

kpaxqin 关注了用户 · 2018-04-01

司徒正美 @situzhengmei

穿梭于二次元与二进制间的魔法师( ̄(工) ̄) 凸ส้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้

关注 2153

kpaxqin 赞了文章 · 2017-11-20

我在 ClojureScript 的 2017

原帖 http://clojure-china.org/t/cl...

2016 年我在 React 社区就不是非常活跃了, 虽然还会搬运一下新闻, 也密切关注着 Jordan Walke 的消息, 但是对于 React 社区的新技术很少去上手用了. 再说自己搞了 Respo, 从此怼 React 怼 Vue 就有了资本, 脾气当然是更不温顺了. 我自认为是 ClojureScript 社区的成员, 这一点很久都不会变了.

Cirru Editor

不过, 虽然我写 ClojureScript, 但是我并不用 EMACS, 或者其他 Clojure 用户爱用的 IDE. 从一开始, 我的热情就是 Cirru 的方案, 直接用网页编辑 S-Expression, 再依靠程序生成文本的代码. Clojure 有着强大的宏, 实际上实现起来反而比 JavaScript 不要简单太多了. 于是我这么做了, 搞得我很孤僻的样子.

最开始 Clojure 社区对我表示赞同和鼓励的人是 Shaun, 他是 Parinfer 的作者. Parinfer 是一个怼 Clojure 代码进行自动格式化的工具, 就像 JavaScript 这边的 Prettier, 虽然当时 Prettier 还没开发呢. Parinfer 发布的时候还上了 Hacker News, 赚了 600+ 点赞. 然后我发现在底下有个 "acknowledgements" 部分, 里面把 Cirru 列进去了:

Cirru Sepal in Clojure has an interesting approach to inferring Clojure parens from indentation and other syntax sugar— $, $ [], ${} and ,.

那个是早期的 Cirru 的文本的语法, 基于缩进的版本. 因为 Parinfer 发布挺早的. 后来, 到了今年, Shaun 在 Clojure Conj 有个演讲 Inspiring a future Clojure editor with forgotten Lisp UX - Shaun Lebron, 为此他跟我沟通了一遍关于 Cirru 的细节. 虽然因为意外没有深入的参与, 但是渐渐我发现 Cirru 探索的方向算是得到了他的认可和鼓励, 并且近期又被引用了一次, 这次感觉真是美滋滋的了.

shadow-cljs

夏天的时候我对 ClojureScript 真的觉得不耐烦了, 忘了具体的肇因, 就是很郁闷. 毕竟等了很久, Webpack 的生态在逐渐加强, 而 ClojureScript 我连一个舒服的打包工具都用不了. 特别是不能生成像 Webpack 那样生成文件名 Hash, 以及做 Code Splitting. 我在冷清的邮件列表开了帖子, 要是英语够好简直想骂街了. 这时候遇到了 Thomas Heller, 跟我解释了半天说办法是有的. 最后我就向他要文档了.

当时 shadow-devtools 工具好像只有他一个人在用, 虽然有文档, 但是就像 Webpack, 鬼知道怎么配置啊. 于是我就一直在 Slack 上问他怎么用. 后来也不知道具体怎么发生的, 他直接把项目改成 shadow-cljs 然后开始加我需要的功能. 最早我想的是既然 Webpack 这么强大吗直接用就好了嘛, ClojureScript 编译成 CommonJS, 或者 ES6. thheller 就给了个 CommonJS 兼容的方案, 不过还是要用 JVM 的 ClojureScript 工具编译. 我想, 总比没有好.

后面 shadow-cljs 实际上提供了 npm 模块直接安装的, 我就不用配置 JVM 了, 关于这一点我是很意外的. 大概这是很大一个好感的来源吧.
于是最开始我就在 Webpack 里用起了 shadow-cljs 生成的 npm-module 格式代码了. 新项目毕竟坑多的, 不过还好 thheller 真是很有激情, 我在 GitHub 上开 issues, 经常隔夜就发现他修好了还加上了功能. 这样我反而不抱怨了. 渐渐地他说服了我, shadow-cljs 有很多 Webpack 能做的功能, 比如动态加载, 比如 Code Splitting, 比如监视文件热替换, 我渐渐也转移到他推荐的开发方案上来了.

那还是夏天, 挺顺利的. 后来因为我失恋, 又出去到处玩, 有一段时间就没经常上去看他的消息了. 直到后来偶然在 Slack 上聊起来, 他说在对付 npm 模块引用的问题, 他想在用 Webpack 帮助来做 npm 模块的打包, 想了几个月没有好办法. 打算试试其他的打包工具, 我跟他说了下 RequireJS, Browserify 那些破事, 他当时觉得挺好, 就想试一试. 可惜后来问题还是很棘手, 直到最后我都不清楚他是怎么解决的. 那段时间 ClojureScript 官方编译器也在处理 npm 模块打包的事情, 总之后来是给出了不错的方案了. 所以也就是秋天的事情, 浏览器模式的 ClojureScript 能引用 npm 模块了, 甚至在 shadow-cljs 里还提供了一下私有的方法.

总之事情过了半年, 我对 thheller 也是服了. 如果你查看 npm info shadow-cljs 的话, 除了七八月份他好像也经历了一些傻逼事情以外, 整个 shadow-cljs 几乎每天都在更新. 从最开始的打包工具, 一步步加入了各种 Webpack 当中实现过的先进的开发方案. 我嫌弃报错看不懂, 他于是加上颜色加上提示, 我嫌 JVM 冷启动太慢, 他优化了 server 模式然后说可以更快的, 然后也有了 Webpack 那样的 manifest.json 文件用于打包, 他自己还加上个简单粗暴 CSS 热替换. 这中间他的用户越来越多, 更多的功能也被优化起来了. 对我来说使用体验在追赶 Webpack 了.

起先的时候, 为了方便查询和传播, 我就注意留一些基础的教程和 demo, 写不来就催着 thheller 要文档. thheller 很忙懒得写文档, 但好歹也写了一些. 后来我上 Reddit 挺多的, 于是也帮着推销 shadow-cljs, 顺便对 boot-cljs 和 lein-cljsbuild 追加嘲讽. 安利的人多了, 然后就有人在 Reddit 上问 shadow-cljs 究竟怎么回事, theller 上去回答了, Shaun 跟 thheller 也有联系, 所以也去声援了. 我就觉得怼 boot 怼 lein 有望了. 至少从现在起这个山头就立在这了, shadow-cljs 是目前对 JavaScript 开发者最友好, 功能也最完善的 ClojureScript 编译工具.

Cirru Cumulo Editor

上半年的时候我还在用着 Stack Editor, 逐渐把存储文件迁移到了 ir.edn, 做了一些生成定义啦, 查找依赖啦, 各种的优化. 失恋加上离职那段时间有了空闲时间, 突然想到了一个解决实时协同编辑的一个方案, 比如一个列表, 我给每个节点生成有序的 id, 支持无限差值, 那么协同编辑冲突就能处理了, 于是集中精力实现了 bisection-key. 虽然基于已有的 Cumulo 同步方案, 重写了整个编辑器, 取名叫做 cumulo-editor.

我当时觉得真的是很强大的突破. 就在 SHLUG 的聚会上找人试用, 到了 JSConf 的时候带着 Pad 也给人看了. 自己觉得非常得意. 那段时间还录了不少 Cumulo Editor 的视频给人看. 刚开始就想着, 反正我离职了时间很多, 我继续加上功能, 也许还能找到试用的场景. 可是实际情况并没有想象当中美好, 我折腾了三周, 没啥效果, 虽然几个朋友给了好评, 但是我连陪我测试的人都没有.

八月份我肠胃炎之后心情更差了. 躲着烦恼去逛了下福建广州, 得到不少别的开导吧, 但是因为晒伤了后来就躲起来不爱见人了. 后面去阿里又面试, 在杭州触景生情, 想想还是赖在上海吧. 接着机会就调整节奏回来做 React 了. 不过 React 啊 Vue 啊折腾了几年, 我对 JavaScript 的好感已经没剩多少了, 我的思维习惯也切换到了 Clojure, 我用 Cirru Editor 写代码的顺畅程度远远高于 JavaScript. 当你自己搞出点东西的时候, 就算去大神鼻子底下装逼, 也是有底气的.

现在 Cumulo Editor 可以很方便地跳转到定义, 快速地搜索和切换函数, 表达式的复制剪切也挺方便的, 以及一些原始的重构功能. 放在很多年轻, 很难想象一个 DOM 编辑器处理代码能够方便到这种程度, 但是现在至少凭借一己之力我做出了点样子来, 而且我能看到后续有更多的改进.

Respo

由于编辑器也是依赖 Respo 的, 实际上 Respo 在我个人的场景当中被大量使用了. 因此中间也一直被改进着. 最有效而且明确的改进就是 defcomp 这个 Macro 的引入了, 实际上比如 div 这些标签也是通过 Macro 定义的. 为了批量定义出这些标签也花费了不少的心思. 一开始是 Macro 的调试, 关于 foo.core$macros 这个奇怪的写法我愣是找到 Lumo 作者问了找搞明白问题, 后来还给他们找到 bug 来了.

调试 Macro 需要一些经验, 虽然本身不是很难, 用 expandmacro-1 也足够, 但是一开始会有坑. 比如 symbol 被多次 evaluate 啦, 比如没有意识到需要 gensym 来避免变量重名啦, 或者 list 被意外处理成 eval 之类的. 经过足够多的例子, 算是那掌握好基本的用法了.

Respo 早一点还有一次大的改进, 就是对 state tree 的方案进行了简化, 或者说改成了半自动半手动的写法. 把原来基于节点定位的 state tree 改成了手动控制结构的 state tree, 导致了大量依赖 Respo 的项目都需要跟着更新. 包括我后来的其他 Respo 上的更改, 比如 list->, 也导致了很多的工作量.

从夏天开始我就注意到 Respo 相关的代码的适用范围越来越广, 在我个人项目当中涉及到的位置也是越来越多. 同时我的脚手架当中不断引入来自 shadow-cljs, Respo, Cirru 的更新, 维护的成本竟然是越来越高. 近期我还打算看一看 Cumulo 项目下有什么可以突破的地方, 需要处理的仓库算是更多了.

ClojureVerse

我有一些运营 CNode 和 React China 两个论坛的基础的经验, 到了 Clojure 这边少不了论坛, 我比较早拿到了 Clojure China 论坛版主的权限, 但是一直没起来. 我觉得是国内人太少. 后来看到国外有个现成的 ClojureVerse, 一样的 Discourse 系统, 于是要来了版主权限, 时不时就上去刷一刷.

在英语社区混的时间长了, 大致就熟悉了规律. 活跃的地方主要是 Clojurians 这个 Slack group. 如果英语好, 勉强是可以混进去的, 只是说时区不一样很难聊到一起. 然后就是 Reddit/Clojure 这个新闻站了. 虽然貌不惊人, 但是访问量是比较稳定的. 剩下 Twitter 上跟几个社区的核心开发者套近乎, 基本能看到对方发的进展. 几个人当中写博客最勤快的就是 Plank 的作者 Mike Fikes 了. 我甚至怀疑他就是目前 ClojureScript 的主力开发, 因为大量的优化都是看他做的. 而且最近这拨 REPL 的改进, 几乎都是他在引领.

另外还有 Google Groups, 也就是邮件列表. Clojure 的邮件列表还算活跃的, 不过 ClojureScript 的差多了. 再说我也不稀罕这么难用的编辑器和界面. 有好的东西, 就发到 ClojureVerse 上, Markdown, 多开心啊. 而且站长 Plexus 真是挺 nice 的, 解答问题别提多细心了.

而且近期他觉得 ClojureVerse 有重新做起来的希望, 开始准备 Relaunch. 我也是服了他了, 论坛真的活跃起来了, 社区里的大牛好多过来串门的, 不知道他人脉什么情况. 我偶然看到老帖子上提了一下他的经历, 看上去是柏林那边的聚会的组织者... 总之跟国内比比真实牛逼了. 也好吧, 感觉有盼头了.

结尾

最近 Jordan Walke 真实蠢蠢欲动了, ReasonML 3 真的很抢镜. 以我敏锐的嗅觉看, ClojureScript 的竞争对手上场了. 本来我觉得 ClojureScript 这次优化好编译器的体验, npm 的短板也补上, 能痛快点干一脚 JavaScript. 但是前端圈 WebAssembly 之类各种新东西不断, 就没把 ClojureScript 放在眼里. 现在 Reason 3 新语法真的相当亮眼, 比 ClojureScript 传播起来快得多.

我是笃定的 FP 的支持者, ClojureScript 热不起来, 我用 ReasonML 开发也是期待中的, 那么我只能分出精力学 ReasonML 的. 目前 Reason React 只是给出了组件级别漂亮的方案. 等到全局状态和异步操作完善, 估计就需要投入了. TypeScript 虽然牛逼, 但是能有 React 原作者设计的语言更适合 React 吗.

回到 ClojureScript 社区这边的事情, 我既然花费了两年开发了基础组件, 肯定是期望能够形成上层建筑的. 我也会努力证明 ClojureScript 在提升开发效率方面有着强大的一面. 也许 Lisp 真的不适合给开发经验不足因而缺少自律甚至缺少 FP 理论基础的人玩, 但我已经看到了它强大的一面, 我就要把它掌握下来安利给别人. 再者说, ClojureScript 社区的几个开发者我总算混熟了点, 我知道他们能力多强, 也多有想法. JavaScript 社区虽然强大, 但多的是杂音, 多的是妥协, 很让人纠结. 等 WebAssembly 平台的语言起来, 不闹翻天才怪.

最后呢, 你想成为怎样的开发者, 你选将来成为哪个社区的核心开发者那样的人呢?

查看原文

赞 8 收藏 3 评论 0

kpaxqin 赞了文章 · 2017-10-11

shadow-cljs 2.x 使用教程

shadow-cljs 是一个新开发的 ClojureScript 开发和编译工具.
以前编译主要是 lein-cljsbuild, boot-cljs, lein-figwheel,
现在新的工具 Lumo 和 shadow-cljs 也可以完成编译工作了.
特别是 shadow-cljs 的功能覆盖开发当中很多场景, 对 JavaScript 开发者更友好.
对于前端开发者来说, shadow-cljs 上手也非常简单, 不需要去管 JVM 的事情.
安装 shadow-cljs 非常简单, 通过 npm 的命令来就好了:

npm install -g shadow-cljs

比如你有一个 ClojureScript 项目, 命名空间叫 app, 对应目录结构:

$ tree src/
src/
└── app
    ├── lib.cljs
    └── main.cljs

如何编译

就像 Webpack 一样, 编译之前需要有一些配置, 源码在哪里, 编译到哪里, 之类的,
由于 ClojureScript 有着自己的依赖管理工具, 所以依赖也要写在这个文件里:

{:source-paths ["src/"]
 :dependencies []
 :builds {:app {:output-dir "target/"
                :asset-path "."
                :target :browser
                :modules {:main {:entries [app.main]}}
                :devtools {:after-load app.main/reload!}}}}

几个关键的参数大概的意思:

  • :target 表示编译目标, 这里选择 :browser, 生成代码用于在浏览器当中运行.
  • :devtools 表示开发环境的设置, 这里我设置了热替换完成之后执行函数 app.main/reload!
  • :asset-path 资源存储的路径, 相对于 target/, 也影响到网页上引用代码. 默认似乎是 ./.

更多的参数可以到文档站点上查阅: http://doc.shadow-cljs.org/

配置完成之后可以用 shadow-cljs 命令来编译代码, 常用的子命令有:

shadow-cljs compile app # 每个文件直接编译到对应一个 js 文件
shadow-cljs release app # 编译, 进行代码合并和优化, 以及清除 dead code

其中的 app 就是配置里的 :app, 也叫 build id. 也就是说会有多个 build id 可以配置.

开发过程中, 最常用的是 watch 命令, 它就像 webpack-dev-server,
ClojureScript 相比 js 来说每个函数副作用更少, 所以更适合进行热替换,
基于上边的配置, 在每次文件更新, 浏览器就会进行代码热替换, 然后会触发 app.main/reload! 函数:

shadow-cljs watch app # 启动编译器, 监视文件更新自动编译

ClojureScript 有一些基础的静态检查功能, 相当于加强的 lint 工具,
所以编译当中会检查代码代码并打印警告, 以及的浏览器当中弹出警告内容.

此外命令行工具还提供了其他一些开发当中用到的功能:

shadow-cljs cljs-repl app # 有 watch 服务的情况下, 再启动一个连接到浏览器的 REPL
shadow-cljs check app # 进行 release 之前可以做一些检查
shadow-cljs release app --debug # 生成 release 的代码, 同时生成 SourceMaps 等用于调试

更多的子命令可以查阅 http://doc.shadow-cljs.org/

编译目标

shadow-cljs 支持多个编译目标, 也就是对应 :target 的配置, 一般有:

  • :browser 运行在浏览器的代码
  • :node-script 运行在 Node.js 的代码
  • :node-library 可以被 Node.js JavaScript 代码调用的模块
  • :npm-module 遵循 CommonJS 语法的独立的 js 文件

我使用最多是 :browser, 功能完善, 已经能够胜任目前网页应用的开发需求了,
而且 :browser 模式的打包也逐渐成熟了, 补上了一些 Webpack 中的常用功能.
在某些只能通过 Webpack 打包情况下, 可以使用 :npm-module 作为一种兼容模式,
:npm-module 模式编译的代码符合 CommonJS 规范, 可以被 Webpack 用于打包(注意这样打包带上 ClojureScript 的 runtime 代码是挺大的).

:node-script 用于开发 Node.js 脚本, 这里热替换也是基本一致的配置.
至于 :node-library 我还没用过, 参考文档应该是暴露结构给 Node.js 脚本调用.

关于这些模式具体的用途, 我搜集了一些例子, 可以参考:

配置项

除了上面的例子, shadow-cljs 的配置项还有不少, 我拿自己的脚手架配置作为例子:

{:source-paths ["src"]
 :dependencies [[mvc-works/hsl          "0.1.2"]
                [mvc-works/shell-page   "0.1.3"]
                [mvc-works/verbosely    "0.1.0-rc"]
                [respo/ui               "0.1.9"]
                [respo/reel             "0.2.0-alpha3"]
                [respo                  "0.6.4"]]
 :http {:host "localhost" :port 8081}
 :open-file-command ["subl" ["%s:%s:%s" :file :line :column]]
 :builds {:browser {:target :browser
                    :output-dir "target/browser"
                    :asset-path "/browser"
                    :modules {:main {:entries [app.main]
                                     :depends-on #{:lib}}
                              :lib {:entries [respo.core respo.macros
                                              respo.comp.inspect]}}
                    :devtools {:after-load app.main/reload!
                               :http-root "target"
                               :http-port 7000}
                    :release {:output-dir "dist/"
                              :module-hash-names true
                              :build-options {:manifest-name "cljs-manifest.json"}}}
          :ssr {:target :node-script
                :output-to "target/ssr.js"
                :main app.render/main!
                :devtools {:after-load app.render/main!}}}}

其中出现了些前面没有有道的配置, 我拎出来解释一下:

shadow-cljs 内置了一个 HTTP 服务器用于网页的调试,
需要在 :devtools 的配置当中添加 HTTP 相关的配置:

:devtools {:http-root "target"
           :http-port 7000}

watch 模式当中遇到代码存在顺发错误, 浏览器上会有界面显示 warning,
shadow-cljs 支持点击代码打开编辑器对应的行, 通过配置打开文件的命令,
传输我用 Sublime Text 打开, 这个命令当中精确到行列:

 :open-file-command ["subl" ["%s:%s:%s" :file :line :column]]

前端单页面应用倾向于生成代码到 vendor.jsmain.js 两个文件,
shadow-cljs 支持将生成代码拆分为多个文件, 这里就拆分成了 main.jslib.js,
并且, 其中指定了 mainlib 的依赖, 以及 lib 包含哪些命名空间:

:modules {:main {:entries [app.main]
                 :depends-on #{:lib}}
          :lib {:entries [respo.core respo.macros
                          respo.comp.inspect]}}

开发环境的配置, 除了常用的 :after-load, 还有 :before-load 等:

:devtools {:after-load app.main/reload!}

注意 :release 的配置是写在 :browser 配置内部的, 表示覆盖重复的配置,
这是 shadow-cljs 提供的一个简写, 你也可以自己专门写一遍 :release 的配置.
比如说 :output-dir "dist/" 就覆盖了外面的配置 :output-dir "target/".
:module-hash-names 声明对生成的文件名加上 MD5 方便放 CDN.
最后一行的配置是重命名 manifest.json 文件, 其中包含前面生成的带 MD5 的文件名,:

:release {:output-dir "dist/"
          :module-hash-names true
          :build-options {:manifest-name "cljs-manifest.json"}}}

:release 的配置可也支持别的配置, 比如这里的 8 表示 Hash 的长度,
manifest 文件除了 JSON, 也可以通过文件后缀支持生成 EDN 文件:

:release {:output-dir "dist/"
          :module-hash-names 8
          :build-options {:manifest-name "assets.edn"}}}

可以看很多随着 Webpack 而在前端广泛使用的功能, 在 shadow-cljs 当中做了不少的支持.
代码拆包以后, shadow-cljs 不好做异步加载, 这个是有些不足, 可以向官方反馈.

npm 模块

shadow-cljs 2.x 版本带来了在 :browser 编译目标的 npm 模块的支持, 注意写法:

(ns app.main
  (:require ["hsl" :as hsl]))

(hsl 200 80 80)

:node-script 编译目标或者 :npm-module 当中也支持这样写:

(def hsl (js/require "hsl"))

(hsl 200 80 80)

因为 require 在 Node 当中直接是函数, 在前端也可以被 Webpack 进一步处理.
大部分 npm 模块都可以直接用到的 ClojureScript 项目当中.
除此之外, Lumo 和官方的 ClojureScript 编译器也改善了对 npm 模块支持.

小结

shadow-cljs 的文章已经做得比较完善, 可以访问 http://doc.shadow-cljs.org 查阅.
如果遇到问题或者想要反馈, 可以通过下面两个地址提交:
https://github.com/thheller/s...
https://clojureverse.org/c/pr...
英语够好的话甚至直接到聊天室上找到作者, 作者在欧洲, 注意时差:
https://clojurians.slack.com/...
2017 年秋天至今 shadow-cljs 作者都在很积极更新功能,
很多的 bug 都以非常快的速度修复了, 让 shadow-cljs 更加友好.

查看原文

赞 3 收藏 2 评论 6

kpaxqin 关注了用户 · 2017-09-07

王下邀月熊_Chevalier @wx_chevalier

爱代码 爱生活 希望成为全栈整合师
微信公众号:某熊的技术之路

关注 3453

kpaxqin 赞了文章 · 2017-06-13

用 ClojureScript 语法运行 React

推广一下 ClojureScript 入门指南 http://cljs-book.clj.im/

得益于最近 ClojureScript(简称 cljs) 社区的发展, 运行和编译 cljs 已经越来越方便.
刷一篇文章来展示一下如何用 ClojureScript 来模仿前端写法运行 React.

执行 ClojureScript 代码

如果你只是想执行一下 cljs 代码熟悉语法, 可以直接安装 Lumo.
Lumo 是一个基于 V8 开发的 cljs 运行环境, 支持 Node.js API.
你可以通过多种方式安装 Lumo:

$ npm install -g lumo-cljs
$ brew install lumo

安装完成之后可以从命令行直接启动:

$ lumo
Lumo 1.5.0
ClojureScript 1.9.542
Node.js v7.10.0
 Docs: (doc function-name-here)
       (find-doc "part-of-name-here")
 Source: (source function-name-here)
 Exit: Control+D or :cljs/quit or exit

cljs.user=> (println "Hello world!")
Hello world!
nil
cljs.user=>

或者也可以用把代码贴到一个文件里, 然后通过 -i 这个参数来运行文件:

lumo -i main.cljs

你可以把这个文件保存下来试着用 Lumo 运行, 注意依赖的几个 React 模块:

(ns demo.server-render)

(def React (js/require "react"))
(def ReactDOM (js/require "react-dom/server"))
(def create-class (js/require "create-react-class"))

(def comp-demo
  (create-class
    #js {:displayName "demo"
         :render (fn []
                    (.createElement React "div" nil))}))

(println "This is only a demo.")
(println
  (.renderToString ReactDOM (.createElement React comp-demo nil)))

运行 React

用 cljs 来写网页会复杂一些, 因为涉及到编译, 也涉及到引用 npm 模块.
不过现在已经好多了, 新版的 cljs 编译器加上 shadow-cljs 解决了这个麻烦.
shadow-cljs 是一个基于 npm 发布的 cljs 编译器, 所以直接用 npm 就能安装.

npm install shadow-cljs

cljs 编译器有 JVM 依赖, 需要看下系统是否安装了 Java, 如果没有也可以用 node-jre 代替.

shadow-cljs 支持将 cljs 编译到 CommonJS 格式的代码, 所以原理上很简单.
我们要做的就是配置好 shadow-cljs 的编译流程, 能够生成代码.
完整的代码我放在 GitHub 上, 可以直接下载通过 yarn 运行:
https://github.com/clojure-ch...

项目结构

首先来看一下文件结构:

=>> tree -I "node_modules|target"
.
├── README.md
├── dist
│   └── index.html
├── entry
│   ├── main.css
│   └── page.js
├── package.json
├── shadow-cljs.edn
├── src
│   └── app
│       └── main.cljs
├── webpack.config.js
└── yarn.lock

4 directories, 9 files
配置 shadow-cljs 编译器

除了我们熟悉的 Webpack 开发常用的文件, 还有这样一些文件:

  • shadow-cljs.edn 编译工具的配置文件

  • src/app/main.cljs 这个就是我们的 ClojureScript 代码

shadow-cljs.edn 配置非常清晰:

{:source-paths ["src"]
 :dependencies [[mvc-works/hsl "0.1.2"]]
 :builds {:app {:target :npm-module
                :output-dir "target/"}}}

这是常用的编译到 npm 模块的配置

  • source-paths 编译的源码所在的文件夹

  • dependencies cljs 依赖, 不过这个依赖只是个示例

  • builds 编译配置的集合, 其中 app 就是一种编译配置

  • target 编译目标, 这里的 :npm-module 表示 npm 模块, 也可以是 :browser 或者更多

  • output-dir 生成的文件输出到哪里

然后我们通过 npm 本地安装 shadow-cljs 就可以通过命令行工具启动编译器了:

  "scripts": {
    "watch": "webpack-dev-server --hot-only",
    "compile-cljs": "shadow-cljs -b app --once",
    "watch-cljs": "shadow-cljs -b app --dev"
  },
  "devDependencies": {
    "css-loader": "^0.28.4",
    "shadow-cljs": "^0.9.5",
    "style-loader": "^0.18.2",
    "webpack": "^2.6.1",
    "webpack-dev-server": "^2.4.5"
  },

注意 shadow-cljs 的常用参数:

  • -b 实际上是 --build 的缩写, 表示选择哪个编译配置, 这里选了 app

  • --once 告诉编译器只要编译一次

  • --dev 告诉编译器编译之后继续监视文件, 当文件改变时自动编译

经过这样的配置, 就可以通过命令启动了:

yarn watch-cljs

编译结果会输出在 target/ 当中, 是很多个 .js 文件.
其中 target/app.main.js 是我们想要的代码的入口文件.

编写 React 代码

我们用 cljs 写 React 用到的是 JavaScript Interop.
也就是用 cljs 的语法去写 js 语法的代码, 编译器会生成 js 代码.
下面我们看一遍具体怎样实现, 完整的文件可以看:
https://github.com/clojure-ch...

首先需要在命名空间当中定义 React 的依赖, 主要是三个模块.
文件的命名空间是 app.main, 跟 src/app/main.cljs 的路径相对应.
其中 src/ 前面在配置 source-paths 已经写过了.
在最新的 cljs 当中可以这样引用 npm 模块:

(ns app.main
  (:require ["react" :as React]
            ["react-dom" :as ReactDOM]
            ["create-react-class" :as create-class]))

然后可以用 create-class 这个函数来调用 React.createClass 定义组件,
其中 #js {} 表示这个 HashMap 会转成 JavaScript Object,
cljs 语法里键的位置用关键字语法, 也会被自动转成 JavaScript 属性,
然后还有 React.createElement 这个方法的调用, 是特别的写法:

(def container
  (create-class
    #js {:displayName "container"
         :render
           (fn []
            (.createElement React "div" nil
              (.createElement React "span" nil "Hello world!")))}))

然后是挂载组件. 通过 ReactDOM.render 方法来挂载.
注意在 cljs 当中直接引用浏览器 API 需要借助 js/ 这个命名空间.

(def mount-point (.querySelector js/document "#app"))

(defn render! []
  (.render ReactDOM (.createElement React container nil) mount-point))

刚才的代码用到了很多 React without JSX 的写法, 可以参考官方文档:
https://facebook.github.io/re...

然后我们定义一下初始化的代码, main 在后面的代码呗调用,
还有 reload 时重新绘制界面. 因为我们的例子要支持代码热替换:

(defn main []
  (render!)
  (println "App loaded."))

(defn reload []
  (render!)
  (println "App reloaded."))

这个文件编译之后是一个很难看到 js 文件, 可以看到是支持 CommonJS 规范的.
而且 cljs 一般也会有 SourceMaps 支持, 实际开发当中可以不看编译出来的 js 代码.

var $CLJS = require("./cljs_env");
require("./cljs.core.js");
require("./shadow.npm.react.js");
require("./shadow.npm.react_dom.js");
require("./shadow.npm.create_react_class.js");
var cljs=$CLJS.cljs;
var shadow=$CLJS.shadow;
var goog=$CLJS.goog;
var app=$CLJS.app || ($CLJS.app = {});
goog.dependencies_.written["app.main.js"] = true;

goog.provide('app.main');
goog.require('cljs.core');
goog.require('cljs.core');
goog.require('shadow.npm.react');
goog.require('shadow.npm.react_dom');
goog.require('shadow.npm.create_react_class');
app.main.mount_point = document.querySelector("#app");
app.main.container = (function (){var G__10849 = ({"displayName": "container", "render": (function (){
return shadow.npm.react.createElement("div",null,shadow.npm.react.createElement("span",null,"Hello world!"));
})});
return shadow.npm.create_react_class(G__10849);
})();
app.main.render_BANG_ = (function app$main$render_BANG_(){
return shadow.npm.react_dom.render(shadow.npm.react.createElement(app.main.container,({})),app.main.mount_point);
});
app.main.main = (function app$main$main(){
app.main.render_BANG_();

return cljs.core.println.cljs$core$IFn$_invoke$arity$variadic(cljs.core.array_seq(["App loaded."], 0));
});
app.main.reload = (function app$main$reload(){
app.main.render_BANG_();

return cljs.core.println.cljs$core$IFn$_invoke$arity$variadic(cljs.core.array_seq(["App reloaded."], 0));
});

module.exports = app.main;

//# sourceMappingURL=app.main.js.map
Webpack 配置

然后还需要一个入口文件来处理一下启动的功能, 比如 CSS 和热替换.
Webpack 提供了 module.hot API 用来手动处理热替换.
我们接受整个 app.main.js 依赖的文件更新, 重新执行 require, 并且调用前面的 reload 函数.

require('./main.css');

window.onload = require('../target/app.main').main;

if (module.hot) {
  module.hot.accept('../target/app.main', function() {
    require('../target/app.main').reload();
  });
}

Webpack 的完整配置我就不重复了, 整个链接我贴在这里.
https://github.com/clojure-ch...
开发环境是可以支持 SourceMaps 的, 但是由于性能不理性, 我关掉了.

启动

最后加一个给 Webpack 启动的入口文件, 也就注意一下加载顺序:

<div id="app"></div>

<script type="text/javascript" data-original="main.js"></script>

最后就可以启动整个应用了, 前面写在了 npm scripts 里边:

yarn # 安装依赖
yarn watch-cljs # 启动 cljs 的编译器
# 再开个终端
yarn watch # 启动 Webpack 的开发环境

再打开 http://localhost:8080/ 就可以看到 React 渲染的 "Hello world!" 了.

更多

这篇文章介绍的只是很基础的在 cljs 里调用 React 的写法.
实际开发当中会用类库来写, 写起来更简单, 而且能做一些优化,
目前推荐的类库有 ReagentRum, 会涉及一些高级的 cljs 语法.

学习 ClojureScript 是个比较麻烦的过程, 这个语言大家平时都不习惯,
有兴趣的话倒是可以循着这些链接继续翻下去:
https://github.com/shaunlebro...
https://github.com/clojure-ch...
http://www.braveclojure.com/c...

如果你想了解 shadow-cljs 编译器更多的用法, 可以查看 Wiki:
https://github.com/thheller/s...
我也写了几个常用功能的例子, 又可以直接 clone 到本地通过 yarn 运行:
https://github.com/minimal-xy...

另外我最近(06-13)在 SegmentFault 有个简短分享, 感兴趣的话可以来评论.
ClojureScript 带给 React 项目的借鉴意义

查看原文

赞 4 收藏 2 评论 7

kpaxqin 发布了文章 · 2017-05-24

Redux状态管理之痛点、分析与改良

如何设计Redux的store?

这几乎是Redux在实践中被问到最多的问题,或许你有自己的方式,却总觉得哪里不太对劲。这篇文章希望从状态是什么,到Elm中的状态管理,最后与Redux分析和对比,试图找到问题,并推导可行的改良方式。

哪些状态需要被管理?

Domain data

Domain data非常好理解,他们直接来源于服务端对领域模型的抽象,比如user、product。它们可能被应用的多个地方用到,比如当前user包含的权限信息所有涉及鉴权的地方都需要。

通常,前端对Domain data最大的管理需求是和服务端保持同步,不会有频繁和复杂的变更——如果有的话请考虑合并批处理和转移复杂度到服务端。

甚至有不少页面仅在初始化时获取一次Domain data,从此就再无瓜葛,直到跳转到下一个页面。

UI state

决定当前UI如何展示的状态,比如一个弹窗的开闭,下拉菜单是否打开。

在我看来,UI state是前端真正开始复杂的部分——如果仅仅依靠服务端拿下来的Domain data就能做好前端,backbone的Model早就一统江湖了,没后来者们什么事情。

和Domain data的简单、稳定不同,UI state是多变,不稳定的——不同的页面有不同、甚至相似但又细微不同的展现和交互。

同时,UI state之间也是互相影响的,比如选择列表中的元素(选中状态是ui state),当选中数量低于N时禁用提交按钮(按钮是否禁用也是ui state)。这是前端工作中非常常见的需求,整个场景中没有Domain data出现。

UI state多变、不稳定,但它仍然是需要被复用的。小到弹窗的开闭,大到表单的管理,他们的逻辑都是明显可被抽象的。

App state *

App级的状态,例如当前是否有请求正在加载。个人倾向将它们视为另一种抽象角度下的UI state。因为本质上它们仍然是服务于UI的:一个异步下拉框会发请求,加载页面主要信息也会发请求,而我们通常希望前者加载时只disable下拉框,而后者可能要用Loading mask遮罩整个页面——场景不同,对状态的需求就不同,单纯关注当前是否有请求正在加载没有意义,只有与UI场景结合才会产生价值,因此我倾向认为App state的本质是对UI state的再抽象。

Redux社区的主流实践

由Redux库贡献者之一维护的recipes提到了

Because the store represents the core of your application, you should define your state shape in terms of your domain data and app state, not your UI component tree.

这基本代表了如今社区的主流实践,它包含了两个主要观点:

  1. Store代表了应用的状态(store represents the core of your application)

  2. 使用domain data和app state作为store的主要抽象依据

很少有人质疑过这两点的正确性,因为第一点和Flux社区一脉相承,第二点无论看起来还是写起代码来都显得顺理成章。

有没有可能这两点才是Redux实践的问题所在?

在往下讨论之前,不妨看看Redux最重要的借鉴对象——Elm是如何管理状态的。

Elm 中的状态树

Elm简介

先用一张图表达Elm的架构:
elm

图:https://staltz.com/unidirecti...

结合代码往下看,首先在Elm中定义一个组件Counter,没有Elm相关基础也没关系,可以结合注释理解大概即可:


-- 定义数据模型
type alias Model = Int

-- 定义消息
type Msg = Increment | Decrement

-- 定义更新函数
update : Msg -> Model -> Model
update msg model =
  case msg of
    Increment ->
      model + 1
    Decrement ->
      model - 1

-- 定义渲染函数
view : Model -> Html Msg
view model =
  div []
    [ button [onClick Decrement] [text "-"]
    , text (toString model)
    , button [onClick Increment] [text "+"]
  ]

-- 定义初始数据
initModel : Model
initModel = 3

有人可能要问了,"组件呢?在哪?这几个变量哪个是组件?"。答案是:加在一起就是。

这是Elm架构的标志:每个组件都被分成了Model/View/Update/Msg四个部分。

当它需要作为应用单独运行时,就将这几个部分"绑"在一起:

main = App.beginnerProgram {model = initModel, view = view, update = update}

而当它需要被上层组件使用时,则由上层组件使用这些分立的元件构建自己的对应部分,下面是使用Counter构建一个CounterList:

以下主要关注对Counter.XXX的使用

import Counter

-- 使用Counter.Model组合新的Model
type alias IndexedCounter = {id: Int, counter: Counter.Model}
type alias Model = {uid: Int, counters: List IndexedCounter}


-- 使用Counter.Msg 组合新的Msg
type Msg = Insert | Remove | Modify Int Counter.Msg

update : Msg -> Model -> Model
update msg model =
  case msg of
    Modify id counterMsg ->
      let
        counterMapper = updateCounter id counterMsg -- 调用updateCounter函数
      in
        {model | counters = List.map counterMapper model.counters}

-- 调用Counter.update
updateCounter : Int -> Counter.Msg -> IndexedCounter -> IndexedCounter
updateCounter id counterMsg indexedCounter =
  if id == indexedCounter.id
  then {indexedCounter | counter = Counter.update counterMsg indexedCounter.counter}
  else indexedCounter

view : Model -> Html Msg
view model =
  div []
    [ button [onClick Insert] [text "Insert"]
    , button [onClick Remove] [text "Remove"]
    , div [] (List.map showCounter model.counters) -- 调用showCounter
    ]

-- 调用Counter.view
showCounter : IndexedCounter -> Html Msg
showCounter ({id, counter} as indexedCounter) =
  App.map (\counterMsg -> Modify id counterMsg) (Counter.view counter)

-- 调用Counter.initModel
initModel = {uid= 0, counters = [{id= 0, counter= Counter.initModel}]}

可以看到,上层组件同样是分成了四个部分,而每个部分都分别调用了子组件的对应元素。

整个Elm的组件树,就是这样一层层组合起来,直到最顶层,仍然是分立的四部分,需要运行时,才被粘合到一起。

最终被运行的根节点组件,无论是Model、View还是Update,都是由整个组件树上无数个小组件组合出来的,在组合的过程中,只有使用A组件的Model,而不会有使用User Model——整个架构从抽象、到组合,都是完全面向组件,而非面向领域模型的。

Redux与Elm的差异

在谈论Elm的Model/Update/Msg时,熟悉Redux的读者应该很快就联想到了Store/Reducer/Action,然而它们间的差异也是显而易见的:Elm中Model/Update/Msg/View创造组件时定义的,而Redux中的Reducer/Action则是在组件树之外定义的。

脱离具体的组件与交互场景,面向组件抽象就变得非常困难,此时领域模型成了几乎唯一可靠的抽象依据。

领域模型与组件树无关,加上之前flux社区的惯性,社区很自然就把store做成了App级的全局单例。

然而,管理UI state的需求仍然存在,一个Web应用可以有无数个页面,相应地有无数的UI state需要管理,如果状态管理框架不能有效地解决它们,也就失去了存在的意义。

在Elm中,应用的状态树随着组件树而变化,假设组件树的根结点是页面,那么页面A和B的状态树必然是不同的,而Redux却需要用唯一一个状态树,去满足整个应用——N个组件树(页面)的需求,这显然是有问题的。

因此在Redux中有reselect, 有normalize,有mapStateToProps,这些Elm中通通不存在的东西,它们面向的其实是同一个问题:状态树到组件树如何映射。然而它们都只能起缓冲作用,因为状态树与组件树一对N的关系并没有改变。

举个例子:A页面有个复杂的Counter组件,我们希望它被状态管理框架管理起来——这显然比setState更清晰更易维护。于是我们设计了counterReducer,并把它放到了store中:

const rootReducer = combineReducers({
  user: userReducer,
  product: productReducer,
  //添加counterReducer
  counter: counterReducer,
})

假设B页面用到了同样的组件——但是需要两个counter,现有的状态树就无法满足需要了,只能改成:

const rootReducer = combineReducers({
  user: userReducer,
  product: productReducer,
  //添加counterReducer
  pageA: combineReducers({
      counter: counterReducer,
  }),
  pageB: combineReducers({
      counter1: counterReducer,
      counter2: counterReducer,
  })
})

这个例子既体现了Redux相对于Flux的进步(在Flux/Reflux中,要复用counter的逻辑非常困难),也体现了Redux在store设计上的尴尬:

  1. Domain data与UI state混搭

  2. 理论上页面有无穷多个,未来rootReducer里还需要装下page(CDEFG)

  3. rootReducer具有全局性,而页面、组件通常是局部的,修改全局去服务局部是bad smell

"如何设计Redux的store?"这个问题的背后,便是如上所述的,Redux在设计上相对于Elm的偏离导致的。这种偏离导致Redux仍然不能非常好地驾驭UI state,最终不得不表示"You might not need Redux"和"setState is OK"。

Reducer的优势

客观地讲,脱离组件树定义的Reducer并非一无是处。它确实很难处理细碎、嵌套的UI状态。但在处理某一"类"UI状态时却显得得心应手——有些UI状态是可以被脱离组件树抽象的(类似前面提到的App state)。

一个著名的例子是redux-form,它把表单这一"类"行为进行了抽象,并且挂载在根reducer下:

import { createStore, combineReducers } from 'redux'
import { reducer as formReducer } from 'redux-form'

const reducers = {
  // ... your other reducers here ...
  form: formReducer     // <---- Mounted at 'form'
}
const reducer = combineReducers(reducers)
const store = createStore(reducer)

类似的例子还有全局的错误处理、loading状态管理以及模态窗的开闭管理。他们都是脱离组件树定义Reducer带来正面价值的案例——对于行为高度固定的、没有复杂嵌套关系的UI状态,脱离组件树几乎不会带来抽象上的缺失,用全局的方式进行抽象是可行的。

题外话:WebApp场景下的隐患

Store对象存在于内存中,在用户没有刷新的情况下是一直存在并且可访问的,而一旦用户刷新、分享链接,Store就会重新创建。由于Store是"应用"级的,开发者使用Store中的数据时,很难知道数据在刷新、分享后是否可用。

举个我曾经在另一篇博客中提到过的例子,一个业务流程有三个页面A/B/C,用户通常按顺序访问它们,每步都会提交一些信息,如果把信息存在Store中,在不刷新的情况下C页面可以直接访问A/B页面存进Store的数据,而一旦用户刷新C页面,这些数据便不复存在,使用这些数据很可能导致程序异常。

如果在设计Store时,是像上面提到的store.pageA这样的形式,情况会稍有缓解,因为至少开发者知道这个数据属于pageA,对数据的来源有认知,如果Store是按领域模型划分的,情况会变得非常糟:开发者在使用store.user这样的数据时不可能知道这个数据是否可靠,最终要么花费额外的精力去确认,要么给应用留下隐患——显然后者会是更常见的情况。

Store这个名字给人以"Storage"的错觉,面向领域模型的设计使得这种错觉被进一步巩固。

从辩护的角度,这个问题不是Redux独有,它是App级Store在Web场景下的通病,从Flux/Reflux开始就已经存在。另外也可以把问题推给开发者:你不确认数据的可靠性,出了问题怪谁?

然而,好的框架、范式应该具备足够的"防御性",当前Redux的主流实践在这个问题上并没有给出让人满意的答案。

例:React-Redux的Real-World example就把分页信息存进了store导致刷新后页码丢失

改良版的实践

尽管Redux有上面提到的问题,但它在单向数据流、提倡纯函数、解耦输入与响应等方面仍然有非常大的价值。对上面提到的问题,我试图通过改良实践去缓解:Page独立声明reducers并创建store

这个过程可以使用高阶组件封装起来,代码:

const defaultConfig = {
  pageReducers: {},
  reducers: commonReducers, // import from other files
  middlewares: commonMiddlewares, // import from other files
};

const withRedux = config => (Comp) => {
  const finalConfig = {
    ...defaultConfig,
    ...config,
  };

  const { middlewares, reducers } = finalConfig;

  return class WithRedux extends Component {
    constructor(props) {
      super(props);

      const reducerFn = combineReducers({
        ...finalConfig.pageReducers,
        ...reducers,
      });

      this.store = applyMiddleware(
        ...middlewares,
      )(createStore)(reducerFn);
    }
    render() {
      return (
        <Provider store={this.store}>
          <Comp {...this.props} />
        </Provider>
      );
    }
  };
};

接下来,只需要在依赖Redux的页面使用withRedux即可:

const PageA = ()=> <div>A</div>;

export default withRedux({
  pageReducers: {
    foo, // 和commonReducers合并成最终页面的reducer
  },
//  reducers: {}, // 直接替换commonReducers
})(PageA)

它可以从两方面缓解上述问题:

  1. 抽象问题:每个Page独立创建store,解决状态树的一对多问题,一个状态树(store)对应一个组件树(page),page在设计store时不用考虑其它页面,仅服务当前页。当然,由于Reducer仍然需要独立于组件树声明,抽象问题并没有根治,面向领域数据和App state的抽象仍然比UI state更自然。它仅仅带给你更大的自由度:不再担心有限的状态树如何设计才能满足近乎无限的UI state。

  2. 刷新、分享隐患:每个Page创建的Store都是完全不同的对象,且只存在于当前Page生命周期内,其它Page不可能访问到,从根本上杜绝跨页面的store访问。这意味着能够从store中访问到的数据,一定是可靠的

通过commonReducers/commonMiddleware可以方便复用一些全局性的解决方案,比如redux-thunk/redux-form。页面默认使用commonReducers/commonMiddlewares,也可以完全不用,甚至页面可以不使用redux复用行为,而不是共用状态,这是Redux相对于Flux最大的进步,现在我们将这个理念继续推进。

问题

Q:是否违反了Redux三大核心原则之一——single source of truth?

A: 没有,它只是明确了组件树和状态树一一对应的关系,一个应用会有N个页面,但不会同时显示两个页面,因此,任何时刻当前页面对应的状态树都是single source of truth。

Q:和社区主流库集成是否会有问题

A: 是的,由于和社区主流实践有差异,遇到问题是难以避免的。

假设你正在使用ReactRouter,采用上述方案后组件树的结构将会变成 Router > Route > Provider > PageA,而react-router-redux则需要 Provider > ConnectedRouter > Route > PageA 这样的组件结构:ConnectedRouter是react-router-redux引入的,依赖Provider向context中注入store,这意味着Redux的Provider必须是路由的父元素,和我们将Redux下放到页面的思路相冲突。

对此,我们的选择是:放弃react-router-redux

我强烈建议你回顾当初引入 react-router-redux的原因:如果是希望通过action操作history,那么一个独立的中间件可以轻易做到;如果是希望通过store访问location/history,在页面初始化时把location/history放进store也非常简单;如果不知道为什么,仅仅因为它是全家桶的一部分——何不干掉他试试?

在移除react-router-redux后,我们不仅没有受到任何功能性的影响,反而使得架构层面的耦合更低了:路由与状态管理方案不再有耦合关系。

这种从耦合中解放的感觉就像水里穿着衣服游泳的人终于脱掉了外套,之前是视图(react)-路由(router)-状态管理(redux)相互耦合,却并没有带来明显的收益,而现在我们已经开始考虑换掉react-router了。

甚至,既然是由页面决定是否引入Redux、使用哪些reducers/middlewares,那么一个项目中不同的页面采用不同技术栈是完全可行的,这允许你在某些页面上大胆尝试新的方案而不用担心影响全局:架构上的低耦合使我们拥有更多的选择余地。

Q: 谈到UI state,社区有以redux-ui为代表的方案,怎么看?

A: 它们恰恰呼应了本文提到的另一个侧面:Reducer的抽象问题。redux-ui让组件状态、行为与组件定义重新回到了一起,从而使"让redux管理UI state"变得更自然。当然它也带来了一些代码结构上的限制,是否采用取决于具体场景下的考量。它和本文最后提倡的改良实践并不冲突,甚至,改良版实践能更容易地在部分页面先行尝试这些新方案。

小结

本文从Elm的角度剖析了Redux存在的问题,也分享了我目前采用的实践方式,这个实践方式不是神奇药水,仅仅是权衡问题和现状后的小步改良。

回顾和对比主流实践的两个重点:

改良前改良后
Store代表了应用的状态Store代表了页面(根组件)状态
Domain data和App state作为store的主要抽象依据没有本质改变,但加入UI state的影响更低

从程序设计的角度,我相信改良后的实践又进步了一点点:更低的耦合、更准确的对应关系、更可靠的数据依赖,与Elm也更加接近。

同时我也深知这还远远不够,期待能有更好的实践方式和更好的轮子出现。

更新

======================= 2017.08.27 更新 =======================

这个方案在实践中,仍然遇到了一些问题,其中最最重要的,则是替换store后,跨页面action的问题

举个例子,通过thunk在a页面触发一个异步action:

const asyncAction = ()=> (dispatch)=> {
  setTimeout(()=> {
    dispatch({type: 'SYNC_ACT'}); // dispatch 为a页面的store.dispatch
  }, 5000)
}

如果在这5秒内,用户跳转到了另一个页面,则会重新create一个store,而回调函数中的dispatch函数仍然指向上一个页面的store。

如果我们把页面看成完全独立的"小应用",这样的行为是说得通的,但作为一个网站有时候我们也希望有"连续"的用户体验和交互。在实际项目中我们遇到的情况是我们使用了redux管理模态窗的开闭状态,而需求方希望在上一个页面离开时打开一个模态窗,同时保持打开状态并跳到下一个页面,两秒后模态窗消失。

同理,如果有类似websocket的需求,相关的thunk action也会不定时地触发dispatch,无论当前在哪个页面。

我反思了一下Elm中的情况,得到的答案是Elm中随着组件树变化的"状态"是纯数据,而store并非如此,它既包含了"状态"数据,也持有了reducer/action之间的监听关系。这一点确实是我最初没有考虑到的。

为了应对这个问题,我考虑了几种方案:

  1. 回到应用单一store:pageReducer的特性通过store.replaceReducer完成。当初为每个页面创建store是想让状态彻底隔离,而在replaceReducer后页面之间如果有相同的reducer则状态不会被重置,这是一个担心点。同时一个副作用是牺牲掉每个page定制化middleware的能力

  2. 为这类跨页面的action建立一个队列,在上个页面将action推进队列,下个页面取出再执行。此方案属于头痛医头,只能解决当前的case,对于websocket等类似问题比较无力。

  3. 定制thunk middleware,通过闭包获取最新的store

在权衡方案的通用性、理解难度等方面后,目前选择了第一种。

其实改变没有想象中的大,只是把withRedux函数改了一下,并且有一部分功能也不再支持,比如页面覆盖commonReducers和定制middleware:

import commonMiddlewares from './commonMiddlewares';
import commonReducers from './commonReducers';

const defaultConfig = {
  pageReducers: {},
  reducers: commonReducers,
  middlewares: commonMiddlewares,
};

export const createReduxStore = (config) => {
  const finalConfig = {
    ...defaultConfig,
    ...config,
  };

  const { middlewares, reducers } = finalConfig;

  const reducerFn = combineReducers({
    ...reducers,
  });

  return applyMiddleware(
    ...middlewares,
  )(createStore)(reducerFn);
};

const store = createReduxStore();

const withRedux = config => Comp => class WithRedux extends Component {
  constructor(props) {
    super(props);

    if (config && config.pageReducers) {
      store.replaceReducer(combineReducers({
        ...commonReducers,
        ...config.pageReducers,
      }));
    }
  }
  render() {
    return (
      <Provider store={store}>
        <Comp {...this.props} />
      </Provider>
    );
  }
};
查看原文

赞 24 收藏 52 评论 16

kpaxqin 发布了文章 · 2016-10-22

Redux异步方案选型

作为react社区最热门的状态管理框架,相信很多人都准备甚至正在使用Redux。

由于Redux的理念非常精简,没有追求大而全,这份架构上的优雅却在某种程度上伤害了使用体验:不能开箱即用,甚至是异步这种最常见的场景也要借助社区方案。

如果你已经挑花了眼,或者正在挑但不知道是否适合,或者已经挑了但不知道会不会有坑,这篇文章应该适合你。

本文会从一些常见的Redux异步方案出发,介绍它们的优缺点,进而讨论一些与异步相伴的常见场景,帮助你在选型时更好地权衡利弊。

简单方案

redux-thunk:指路先驱

Github:https://github.com/gaearon/redux-thunk

Redux作者Dan写的中间件,因官方文档出镜而广为人知。

它向我们展示了Redux处理异步的原理,即:

Redux本身只能处理同步的Action,但可以通过中间件来拦截处理其它类型的action,比如函数(Thunk),再用回调触发普通Action,从而实现异步处理,在这点上所有Redux的异步方案都是类似的

而它使用起来最大的问题,就是重复的模板代码太多:

//action types
const GET_DATA = 'GET_DATA',
    GET_DATA_SUCCESS = 'GET_DATA_SUCCESS',
    GET_DATA_FAILED = 'GET_DATA_FAILED';
    
//action creator
const getDataAction = function(id) {
    return function(dispatch, getState) {
        dispatch({
            type: GET_DATA, 
            payload: id
        })
        api.getData(id) //注:本文所有示例的api.getData都返回promise对象
            .then(response => {
                dispatch({
                    type: GET_DATA_SUCCESS,
                    payload: response
                })
            })
            .catch(error => {
                dispatch({
                    type: GET_DATA_FAILED,
                    payload: error
                })
            }) 
    }
}

//reducer
const reducer = function(oldState, action) {
    switch(action.type) {
    case GET_DATA : 
        return oldState;
    case GET_DATA_SUCCESS : 
        return successState;
    case GET_DATA_FAILED : 
        return errorState;
    }
}

这已经是最简单的场景了,请注意:我们甚至还没写一行业务逻辑,如果每个异步处理都像这样,重复且无意义的工作会变成明显的阻碍。

另一方面,像GET_DATA_SUCCESSGET_DATA_FAILED这样的字符串声明也非常无趣且易错。

上例中,GET_DATA这个action并不是多数场景需要的,它涉及我们将会提到的乐观更新,保留这些代码是为了和下面的方案做对比

redux-promise:瘦身过头

由于redux-thunk写起来实在是太麻烦了,社区当然会有其它轮子出现。redux-promise则是其中比较知名的,同样也享受了官网出镜的待遇。

它自定义了一个middleware,当检测到有action的payload属性是Promise对象时,就会:

  • 若resolve,触发一个此action的拷贝,但payload为promise的value,并设status属性为"success"

  • 若reject,触发一个此action的拷贝,但payload为promise的reason,并设status属性为"error"

说起来可能有点不好理解,用代码感受下:

//action types
const GET_DATA = 'GET_DATA';

//action creator
const getData = function(id) {
    return {
        type: GET_DATA,
        payload: api.getData(id) //payload为promise对象
    }
}

//reducer
function reducer(oldState, action) {
    switch(action.type) {
    case GET_DATA: 
        if (action.status === 'success') {
            return successState
        } else {
               return errorState
        }
    }
}

进步巨大! 代码量明显减少! 就用它了! ?

请等等,任何能明显减少代码量的方案,都应该小心它是否过度省略了什么东西,减肥是好事,减到骨头就残了。

redux-promise为了精简而做出的妥协非常明显:无法处理乐观更新

场景解析之:乐观更新

多数异步场景都是悲观更新(求更好的翻译)的,即等到请求成功才渲染数据。而与之相对的乐观更新,则是不等待请求成功,在发送请求的同时立即渲染数据

最常见的例子就是微信等聊天工具,发送消息时消息立即进入了对话窗,如果发送失败的话,在消息旁边再作补充提示即可。这种交互"乐观"地相信请求会成功,因此称作乐观更新

由于乐观更新发生在用户操作时,要处理它,意味着必须有action表示用户的初始动作

在上面redux-thunk的例子中,我们看到了GET_DATA, GET_DATA_SUCCESSGET_DATA_FAILED三个action,分别表示初始动作异步成功异步失败,其中第一个action使得redux-thunk具备乐观更新的能力。

而在redux-promise中,最初触发的action被中间件拦截然后过滤掉了。原因很简单,redux认可的action对象是 plain JavaScript objects,即简单对象,而在redux-promise中,初始action的payload是个Promise。

另一方面,使用status而不是type来区分两个异步action也非常值得商榷,按照redux对action的定义以及社区的普遍实践,个人还是倾向于使用不同的type,用同一type下的不同status区分action额外增加了一套隐形的约定,甚至不符合该redux-promise作者自己所提倡的FSA,体现在代码上则是在switch-case内再增加一层判断。

redux-promise-middleware:拔乱反正

redux-promise-middleware相比redux-promise,采取了更为温和和渐进式的思路,保留了和redux-thunk类似的三个action。

示例:

//action types
const GET_DATA = 'GET_DATA',
    GET_DATA_PENDING = 'GET_DATA_PENDING',
    GET_DATA_FULFILLED = 'GET_DATA_FULFILLED',
    GET_DATA_REJECTED = 'GET_DATA_REJECTED';
    
//action creator
const getData = function(id) {
    return {
        type: GET_DATA,
        payload: {
            promise: api.getData(id),
            data: id
        }
    }
}

//reducer
const reducer = function(oldState, action) {
    switch(action.type) {
    case GET_DATA_PENDING :
        return oldState; // 可通过action.payload.data获取id
    case GET_DATA_FULFILLED : 
        return successState;
    case GET_DATA_REJECTED : 
        return errorState;
    }
}

如果不需要乐观更新,action creator可以使用和redux-promise完全一样的,更简洁的写法,即:

const getData = function(id) {
    return {
        type: GET_DATA,
        payload: api.getData(id) //等价于 {promise: api.getData(id)}
    }
}

此时初始actionGET_DATA_PENDING仍然会触发,但是payload为空。

相对redux-promise于粗暴地过滤掉整个初始action,redux-promise-middleware选择创建一个只过滤payload中的promise属性XXX_PENDING作为初始action,以此保留乐观更新的能力。

同时在action的区分上,它选择了回归type的"正途",_PENDING_FULFILLED _REJECTED等后缀借用了promise规范 (当然它们是可配置的) 。

它的遗憾则是只在action层实现了简化,对reducer层则束手无策。另外,相比redux-thunk,它还多出了一个_PENDING的字符串模板代码(三个action却需要四个type)。

社区有类似type-to-reducer这样试图简化reducer的库。但由于reducer和异步action通常是两套独立的方案,reducer相关的库无法去猜测异步action的后缀是什么(甚至有没有后缀),社区也没有相关标准,也就很难对异步做出精简和抽象了。

redux-action-tools:软文预警

无论是redux-thunk还是redux-promise-middleware,模板代码都是显而易见的,每次写XXX_COMPLETED这样的代码都觉得是在浪费生命——你得先在常量中声明它们,再在action中引用,然后是reducer,假设像redux-thunk一样每个异步action有三个type,三个文件加起来你就得写九次!

国外开发者也有相同的报怨:

clipboard.png

有没有办法让代码既像redux-promise一样简洁,又能保持乐观更新的能力呢?

redux-action-tools是我给出的答案:

const GET_DATA = 'GET_DATA';

//action creator
const getData = createAsyncAction(GET_DATA, function(id) {
    return api.getData(id)
})

//reducer
const reducer = createReducer()
    .when(getData, (oldState, action) => oldState)
    .done((oldState, action) => successState)
    .failed((oldState, action) => errorState)
    .build()

redux-action-tools在action层面做的事情与前面几个库大同小异:同样是派发了三个action:GET_DATA/GET_DATA_SUCCESS/GET_DATA_FAILED。这三个action的描述见下表:

typeWhenpayloadmeta.asyncPhase
${actionName}异步开始前同步调用参数'START'
${actionName}_COMPLETED异步成功value of promise'COMPLETED'
${actionName}_FAILED异步失败reason of promise'FAILED'

createAsyncAction参考了redux-promise作者写的redux-actions ,它接收三个参数,分别是:

  1. actionName 字符串,所有派生action的名字都以它为基础,初始action则与它同名

  2. promiseCreator 函数,必须返回一个promise对象

  3. metaCreator 函数可选,作用后面会演示到

目前看来,其实和redux-promise/redux-promise-middleware大同小异。而真正不同的,是它同时简化了reducer层! 这种简化来自于对异步行为从语义角度的抽象:

当(when)初始action发生时处理同步更新,若异步成功(done)则处理成功逻辑,若异步失败(failed)则处理失败逻辑

抽离出when/done/failed三个关键词作为api,并使用链式调用将他们串联起来:when函数接收两个参数:actionName和handler,其中handler是可选的,donefailed则只接收一个handler参数,并且只能在when之后调用——他们分别处理`${actionName}_SUCCESS` 和 `${actionName}_FAILED`.

无论是action还是reducer层,XX_SUCCESS/XX_FAILED相关的代码都被封装了起来,正如在例子中看到的——你甚至不需要声明它们! 创建一个异步action,然后处理它的成功和失败情况,事情本该这么简单。

更进一步的,这三个action默认都根据当前所处的异步阶段,设置了不同的meta(见上表中的meta.asyncPhase),它有什么用呢?用场景说话:

场景解析:失败处理与Loading

它们是异步不可回避的两个场景,几乎每个项目会遇到。

以异步请求的失败处理为例,每个项目通常都有一套比较通用的,适合多数场景的处理逻辑,比如弹窗提示。同时在一些特定场景下,又需要绕过通用逻辑进行单独处理,比如表单的异步校验。

而在实现通用处理逻辑时,常见的问题有以下几种:

  1. 底层处理,扩展性不足

    function fetchWrapper(args) {
        return fetch.apply(fetch, args)
            .catch(commonErrorHandler)
    }

    在较底层封装ajax库可以轻松实现全局处理,但问题也非常明显:

    一是扩展性不足,比如少数场景想要绕过通用处理逻辑,还有一些场景错误是前端生成而非直接来自于请求;

    二是不易组合,比如有的场景一个action需要多个异步请求,但异常处理和loading是不需要重复的,因为用户不需要知道一个动作有多少个请求。

  2. 不够内聚,侵入业务代码

    //action creator
    const getData = createAsyncAction(GET_DATA, function(id) {
        return api.getData(id)
            .catch(commonErrorHandler) //调用错误处理函数
    })

    在有业务意义的action层调用通用处理逻辑,既能按需调用,又不妨碍异步请求的组合。但由于通用处理往往适用于多数场景,这样写会导致业务代码变得冗余,因为几乎每个action都得这么写。

  3. 高耦合,高风险

    也有人把上面的方案做个依赖反转,改为在通用逻辑里监听业务action:

    function commonErrorReducer(oldState, action) {
        switch(action.type) {
        case GET_DATA_FAILED:
        case PUT_DATA_FAILED:
        //... tons of action type
            return commonErrorHandler(action)
        }
    }

    这样做的本质是把冗余从业务代码中拿出来集中管理。

    问题在于每添加一个请求,都需要修改公共代码,把对应的action type加进来。且不说并行开发时merge冲突,如果加了一个异步action,但忘了往公共处理文件中添加——这是很可能会发生的——而异常是分支流程不容易被测试发现,等到发现,很可能就是事故而不是bug了。

通过以上几种常见方案的分析,我认为比较完善的错误处理(Loading同理)需要具备如下特点:

  • 面向异步动作(action),而非直接面向请求

  • 不侵入业务代码

  • 默认使用通用处理逻辑,无需额外代码

  • 可以绕过通用逻辑

而借助redux-action-tools提供的meta.asyncPhase,可以轻易用middleware实现以上全部需求!

import _ from 'lodash'
import { ASYNC_PHASES } from 'redux-action-tools'

function errorMiddleWare({dispatch}) {
  return next => action => {
    const asyncStep = _.get(action, 'meta.asyncStep');

    if (asyncStep === ASYNC_PHASES.FAILED) {
      dispatch({
        type: 'COMMON_ERROR',
        payload: {
          action
        }
      })
    }
    
    next(action);
  }
}

以上中间件一旦检测到meta.asyncStep字段为FAILED的action便触发新的action去调用通用处理逻辑。面向action、不侵入业务、默认工作 (只要是用createAsyncAction声明的异步) ! 轻松实现了理想需求中的前三点,那如何定制呢?既然拦截是面向meta的,只要在创建action时支持对meta的自定义就行了,而createAsyncAction的第三个参数就是为此准备的:

import _ from 'lodash'
import { ASYNC_PHASES } from 'redux-action-tools'

const customizedAction = createAsyncAction(
  type, 
  promiseCreator, //type 和 promiseCreator此处无不同故省略
  (payload, defaultMeta) => {
    return { ...defaultMeta, omitError: true }; //向meta中添加配置参数
  }
)

function errorMiddleWare({dispatch}) {
  return next => action => {
    const asyncStep = _.get(action, 'meta.asyncStep');
    const omitError = _.get(action, 'meta.omitError'); //获取配置参数

    if (!omitError && asyncStep === ASYNC_PHASES.FAILED) {
      dispatch({
        type: 'COMMON_ERROR',
        payload: {
          action
        }
      })
    }
    
    next(action);
  }
}

类似的,你可以想想如何处理Loading,需要强调的是建议尽量用增量配置的方式进行扩展,而不要轻易删除和修改meta.asyncPhase

比如上例可以通过删除meta.asyncPhase实现同样功能,但如果同时还有其它地方也依赖meta.asyncPhase(比如loadingMiddleware),就可能导致本意是定制错误处理,却改变了Loading的行为,客观来讲这层风险是基于meta拦截方案的最大缺点,然而相比多数场景的便利、健壮,个人认为特殊场景的风险是可以接受的,毕竟这些场景在整个开发测试流程容易获得更多关注。

进阶方案

上面所有的方案,都把异步请求这一动作放在了action creator中,这样做的好处是简单直观,且和Flux社区一脉相承(见下图)。因此个人将它们归为相对简单的一类。

action_creator

下面将要介绍的,是相对复杂一类,它们都采用了与上图不同的思路,去追求更优雅的架构、解决更复杂的问题

redux-loop:分形! 组合!

众所周知,Redux是借鉴自Elm的,然而在Elm中,异步的处理却并不是在action creator层,而是在reducer(Elm中称update)层:

elm_arch

图片来源于: https://github.com/jarvisaoie...

这样做的目的是为了实现彻底的可组合性(composable)。在redux中,reducer作为函数是可组合的,action正常情况下作为纯对象也是可组合的,然而一旦涉及异步,当action嵌套组合的时候,中间件就无法正常识别,这个问题让redux作者Dan也发出感叹 There is no easy way to compose Redux applications并且开了一个至今仍然open的issue,对组合、分形与redux的故事,有兴趣的朋友可以观摩以上链接,甚至了解一下Elm,篇幅所限,本文难以尽述。

而redux-loop,则是在这方面的一个尝试,它更彻底的模仿了Elm的模式:引入Effects的概念并将其置入reducer,官方示例如下:

import { Effects, loop } from 'redux-loop';
import { loadingStart, loadingSuccess, loadingFailure } from './actions';

export function fetchDetails(id) {
  return fetch(`/api/details/${id}`)
    .then((r) => r.json())
    .then(loadingSuccess)
    .catch(loadingFailure);
}

export default function reducer(state, action) {
  switch (action.type) {
    case 'LOADING_START':
      return loop(
        { ...state, loading: true },
        Effects.promise(fetchDetails, action.payload.id)
      ); // 同时返回状态与副作用

    case 'LOADING_SUCCESS':
      return {
        ...state,
        loading: false,
        details: action.payload
      };

    case 'LOADING_FAILURE':
      return {
        ...state,
        loading: false,
        error: action.payload.message
      };

    default:
      return state;
  }
}

注意在reducer中,当处理LOADING_START时,并没有直接返回state对象,而是用loop函数将state和Effect"打包"返回(实际上这个返回值是数组[State, Effect],和Elm的方式非常接近)。

然而修改reducer的返回类型显然是比较暴力的做法,除非Redux官方出面,否则很难获得社区的广泛认同。更复杂的返回类型会让很多已有的API,三方库面临危险,甚至combineReducer都需要用redux-loop提供的定制版本,这种"破坏性"也是Redux作者Dan没有采纳redux-loop进入Redux核心代码的原因:"If a solution doesn’t work with vanilla combineReducers(), it won’t get into Redux core"。

对Elm的分形架构有了解,想在Redux上继续实践的人来说,redux-loop是很好的参考素材,但对多数人和项目而言,最好还是更谨慎地看待。

redux-saga:难、而美

Github: https://github.com/yelouafi/r...

另一个著名的库,它让异步行为成为架构中独立的一层(称为saga),既不在action creator中,也不和reducer沾边。

它的出发点是把副作用 (Side effect,异步行为就是典型的副作用) 看成"线程",可以通过普通的action去触发它,当副作用完成时也会触发action作为输出。

import { takeEvery } from 'redux-saga'
import { call, put } from 'redux-saga/effects'
import Api from '...'

function* getData(action) {
   try {
      const response = yield call(api.getData, action.payload.id);
      yield put({type: "GET_DATA_SUCCEEDED", payload: response});
   } catch (e) {
      yield put({type: "GET_DATA_FAILED", payload: error});
   }
}

function* mySaga() {
  yield* takeEvery("GET_DATA", getData);
}

export default mySaga;

相比action creator的方案,它可以保证组件触发的action是纯对象,因此至少在项目范围内(middleware和saga都是项目的顶层依赖,跨项目无法保证),action的组合性明显更加优秀。

而它最为主打的,则是可测试性和强大的异步流程控制

由于强制所有saga都必须是generator函数,借助generator的next接口,异步行为的每个中间步骤都被暴露给了开发者,从而实现对异步逻辑"step by step"的测试。这在其它方案中是很少看到的 (当然也可以借鉴generator这一点,但缺少约束)。

而强大得有点眼花缭乱的API,特别是channel的引入,则提供了武装到牙齿级的异步流程控制能力。

然而,回顾我们在讨论简单方案时提到的各种场景与问题,redux-saga并没有去尝试回答和解决它们,这意味着你需要自行寻找解决方案。而generator、相对复杂的API和单独的一层抽象也让不少人望而却步。

包括我在内,很多人非常欣赏redux-saga。它的架构和思路毫无疑问是优秀甚至优雅的,但使用它之前,最好想清楚它带来的优点(可测试性、流程控制、高度解耦)与付出的成本是否匹配,特别是异步方面复杂度并不高的项目,比如多数以CRUD为主的管理系统。

场景解析:竞态

说到异步流程控制很多人可能觉得太抽象,这里举个简单的例子:竞态。这个问题并不罕见,知乎也有见到类似问题

简单描述为:

由于异步返回时间的不确定性,后发出的请求可能先返回,如何确保异步结果的渲染是按照请求发生顺序,而不是返回顺序?

这在redux-thunk为代表的简单方案中是要费点功夫的:

function fetchFriend(id){
    return (dispatch, getState) => {
        //步骤1:在reducer中 set state.currentFriend = id;
        dispatch({type: 'FETCH_FIREND', payload: id}); 

        return fetch(`http://localhost/api/firend/${id}`)
            .then(response => response.json())
            .then(json => { 
                //步骤2:只处理currentFriend的对应response
                const { currentFriend } = getState();
                (currentFriend === id) && dispatch({type: 'RECEIVE_FIRENDS', playload: json})
            });
    }
}

以上只是示例,实际中不一定需要依赖业务id,也不一定要把id存到store里,只要为每个请求生成key,以便处理请求时能够对应起来即可。

而在redux-saga中,一切非常地简单:

import { takeLatest } from `redux-saga`

function* fetchFriend(action) {
  ...
}

function* watchLastFetchUser() {
  yield takeLatest('FETCH_FIREND', fetchFriend)
}

这里的重点是takeLatest,它限制了同步事件与异步返回事件的顺序关系。

另外还有一些基于响应式编程(Reactive Programming)的异步方案(如redux-observable)也能非常好地处理竞态场景,因为描述事件流之间的关系,正是整个响应式编程的抽象基石,而竞态在本质上就是如何保证同步事件与异步返回事件的关系,正是响应式编程的用武之地。

小结

本文包含了一些redux社区著名、非著名 (恩,我的redux-action-tools) 的异步方案,这些其实并不重要。

因为方案是一家之作,结论也是一家之言,不可能放之四海皆准。个人更希望文中探讨过的常见问题和场景,比如模板代码、乐观更新、错误处理、竞态等,能够成为你选型时的尺子,为你的权衡提供更好的参考,而不是等到项目热火朝天的时候,才发现当初选型的硬伤。

查看原文

赞 88 收藏 149 评论 14

kpaxqin 赞了文章 · 2016-09-10

[译] 快速介绍 JavaScript 中的 CSP

原文 http://lucasmreis.github.io/b...

Communicating Sequential Processes 的 7 个示例

CSP 是什么? 一般来说, 它是写并行代码的一套方案.
在 Go 语言里自带该功能, Clojure 通过基于 Macro 的 core.async 来实现,
现在 JavaScript 通过 Generator 也能做支持了, 或者说 ES6 的功能.

为什么我要关心 CSP? 因为它强大啊, 而且高效, 而且简单. 都这样了你还想要什么? :)

好吧, 说细节. 怎样使用呢?我们用 js-csp, 而且需要 generator 支持, ES6 才有.
也就说 Node 4 或者更高的版本才行, 或者浏览器代码用 Babel 编译一下,
当然能其他的编译工具可能也行, 但你要确认下是支持 Generator 的.

注: 文章写得早, 现在翻译文章, Chrome 应该是支持 Generator 的.

扯多了, 来看例子吧!

例 1: 进程

第一个要学的概念是"进程". 进程可以执行代码, 简单说就是这样的了. :)

注: 当然不是操作系统原始的进程了, js 里模拟的.

这是启动进程的语法: generator 函数作为参数, 传给 go 函数执行.

import {go} from 'js-csp';

go(function* () {
  console.log('something!');
});

// terminal output:
//
// => something!

例 2: 进程可以暂停

使用 yield 关键字可以暂停一个进程, 把当前进程的占用释放:

import {go, timeout} from 'js-csp';

go(function* () {
  yield timeout(1000);
  console.log('something else after 1 second!');
});

console.log('something!');

// terminal output:
//
// => something!
// => something else after 1 second!

例 3: 进程等待来自管道的数据

第二个要学的概念是管道, 也是最后一个了. 管道就像是队列.
一旦进程对管道调用 take, 进程就会暂停, 直到别人往管道放进数据.

import {go, chan, take, putAsync} from 'js-csp';

let ch = chan();

go(function* () {
  const received = yield take(ch);
  console.log('RECEIVED:', received);
});

const text = 'something';
console.log('SENDING:', text);

// use putAsync to put a value in a
// channel from outside a process
putAsync(ch, text);

// terminal output:
//
// => SENDING: something
// => RECEIVED: something

例 4: 进程通过管道来通信

管道的另一边, 往管道里 put 数据的那些进程也会暂停, 直到这边进程调用 take.

下面的例子就复杂一点了, 试着跟随一下主线, 印证一下终端输出的内容:

import {go, chan, take, put} from 'js-csp';

let chA = chan();
let chB = chan();

// Process A
go(function* () {
  const receivedFirst = yield take(chA);
  console.log('A > RECEIVED:', receivedFirst);

  const sending = 'cat';
  console.log('A > SENDING:', sending);
  yield put(chB, sending);

  const receivedSecond = yield take(chA);
  console.log('A > RECEIVED:', receivedSecond);
});

// Process B
go(function* () {
  const sendingFirst = 'dog';
  console.log('B > SENDING:', sendingFirst);
  yield put(chA, sendingFirst);

  const received = yield take(chB);
  console.log('B > RECEIVED:', received);

  const sendingSecond = 'another dog';
  console.log('B > SENDING:', sendingSecond);
  yield put(chA, sendingSecond);
});

// terminal output:
//
// => B > SENDING: dog
// => A > RECEIVED: dog
// => A > SENDING: cat
// => B > RECEIVED: cat
// => B > SENDING: another dog
// => A > RECEIVED: another dog

立 5: 管道也是队列

由于管道是队列, 当进程从管道取走数据, 其他进程就拿不到了.
所以推数据的是一个进程, 取数据的也是一个进程.

下面这个例子可以看到第二个进程永远不会打印 B > RECEIVED: dog,
因为第一个进程已经把数据取走了.

import {go, chan, take, put} from 'js-csp';

let ch = chan();

go(function* () {
  const text = yield take(ch);
  console.log('A > RECEIVED:', text);
});

go(function* () {
  const text = yield take(ch);
  console.log('B > RECEIVED:', text);
});

go(function* () {
  const text = 'dog'
  console.log('C > SENDING:', text);
  yield put(ch, text);
});

// terminal output:
//
// => C > SENDING: dog
// => A > RECEIVED: dog

例 6: 带缓冲的管道不会在 put 操作时阻塞

管道可以带缓冲, 也就是, 一定数量之内的数据, 执行 put 操作可以避开阻塞.

这个例子里, 即便没有其他进程调用 take, 前两个写操作也不会阻塞进程.
不过管道的缓存数量是 2, 所以第三个数据就阻塞进程了, 直到其他进程取走数据.

import {go, chan, put, buffers} from 'js-csp';

let ch = chan(buffers.fixed(2));

go(function* () {
  yield put(ch, 'value A');
  yield put(ch, 'value B');
  console.log('I should print!');
  yield put(ch, 'value C');
  console.log('I should not print!');
});

// terminal output:
//
// => I should print!

例 7: Dropping And Sliding Buffers

固定大小的缓冲在 N 个数据之后会阻塞, 初次之外, 还有对缓冲的 dropping 和 sliding 控制.

缓冲的 dropping 以为着管道可以持有 N 个数据.
再增加额外的数据放进管道, 管道就会将其丢弃.

缓冲的 sliding 也可以持有 N 个数据. 不过相对于直接丢弃新数据,
sliding 缓冲原先的第一个推的数据会被丢弃, buffer 里会留下新的这个数据.

下面这个例子, value Bvalue C 在 dropping 缓冲里被丢弃, 因为已经有 value A 了.
第二个进程里, 当 value B 被放进管道, value A 就被丢弃了.
然后 value C 放进管道, value B 就被丢弃.

根据它们的工作原理, dropping 和 sliding 的缓冲永远不会阻塞!

let droppingCh = chan(buffers.dropping(1));
let slidingCh  = chan(buffers.sliding(1));

go(function* () {
  yield put(droppingCh, 'value A');
  yield put(droppingCh, 'value B');
  yield put(droppingCh, 'value C');
  console.log('DROPPING:', yield take(droppingCh));
});

go(function* () {
  yield put(slidingCh, 'value A');
  yield put(slidingCh, 'value B');
  yield put(slidingCh, 'value C');
  console.log('SLIDING:', yield take(slidingCh));
});

// terminal output:
//
// => DROPPING: value A
// => SLIDING: value C

结论

CSP 用了一段时间之后, 用回调或者 Promise 写代码就像是侏罗纪的技术.
我希望 ES6 的 Generator 能帮助 CSP 成为 JavaScript 的一个标准,
就像是 Go 已经是的那样, 以及 Clojure 里正在成为的那样.

下一步

另外有两个模型也还有意思, 大概可以认为是比 CSP 层级更高一点的:
函数式也是响应式编程(Rx)跟 Actors, 分别在 Rx 和 Erlang 里用到.
我当然后面也会写博客来挖掘一下.

我同时相信 CSP 对于前端框架来说非常棒.

原作者还有一个文章可以看下: Using CSP as Application Architecture

查看原文

赞 8 收藏 38 评论 6

kpaxqin 赞了文章 · 2016-08-25

Vue 2 服务端渲染初探

写这篇文章, Vue 2 还在 Beta 呢...

参考资料

官方文档写得很清楚

似乎 Vue 1 有看到过通过 jsdom 做后端渲染的例子, 性能不佳.
Vue 2 开始将 Virtual DOM 作为底层实现, 于是模块分离开始支持 SSR.

渲染步骤

4 步走战略~

安装 hackernews 的例子, 完整的 app 渲染的例子包括:

  1. 用 Webpack 的 node 模式把整个应用单独打一个包

  2. Node 环境通过 API 将这个包加载到 vm 环境当中

  3. 应用在 vm 内部启动 HTTP 请求抓取当前路由依赖的数据

  4. 生成网页模板, 将 HTML 和初始数据嵌在中间

如果网页依赖的数据少或者不依赖, 可以简化一点,
比如中间抓取 HTTP 的步骤去掉, 可以简化不少,
也许还可以去掉 vm 那步, 直接通过引用文件来生成 HTML.

渲染 API

两套 API 哦... 好像只用带 bundle 那套...

https://github.com/vuejs/vue/...

  • createRenderer([rendererOptions])

  • renderer.renderToString(vm, cb)

  • renderer.renderToStream(vm)

  • createBundleRenderer(code, [rendererOptions])

  • bundleRenderer.renderToString([context], cb)

  • bundleRenderer.renderToStream([context])

后面三个 API 都带上了 bundle, 此外看上去和前面的一样,
bundle 是通过 Node.js 的 vm 模块运行的, 每次的都重新启动一遍代码,
作者解释这样能清空整个 app 的状态,
我推测这是因为用了 Vuex 之后, 数据会被缓存在内部无法清理,
如果是单纯通过 props 传递数据, 应该是可以用前一套 API.

服务端渲染原理

有了 Virtual DOM 就好办了

VNode 定义 https://github.com/vuejs/vue/...

HTML 渲染的代码, 通过 write 同时支持到了 Stream 输出:
https://github.com/vuejs/vue/...
https://github.com/vuejs/vue/...

如果用 bundle 模式, 注意每次都会运行 vm.runInNewContext 新建环境.
https://github.com/vuejs/vue/...
https://github.com/vuejs/vue/...

最后返回用户的 HTML 其实是拼接出来的,
注意首屏的动态数据, 也通过 window.__INITIAL_STATE__ 发送到浏览器,
https://github.com/vuejs/vue-...

缓存

速度快是因为缓存呢吧...

文档 https://github.com/vuejs/vue/...

大致就是如果组件可以根据一个 key 来确定, 就可以进行缓存,
静态的组件当然是有固定的 key, 动态的组件根据 id 等数据生成 key,

serverCacheKey: props => props.item.id + '::' + props.item.last_updated

如果组件可以找到缓存, 就直接返回缓存内容:
https://github.com/vuejs/vue/...

这也就意味着顶层的组件总之就是不能缓存的, 性能开销免不了.
hackernews 的例子本地用 ab 压了一下, Mac Pro 到 130+qps 了,

Concurrency Level:      100
Time taken for tests:   3.013 seconds
Complete requests:      400
Failed requests:        0
Total transferred:      11545200 bytes
HTML transferred:       11506000 bytes
Requests per second:    132.77 [#/sec] (mean)
Time per request:       753.205 [ms] (mean)
Time per request:       7.532 [ms] (mean, across all concurrent requests)
Transfer rate:          3742.21 [Kbytes/sec] received

但是这个 Demo 是用了缓存的, 破坏掉缓存性能落差很大,
我自己做的 Demo, 实际上加上缓存性能还不到这个一半...
看来跟应用的类型是有关的, 特别是节点偏多的应用影响更大.

数据策略

想象一下后端有个浏览器...

对于依赖数据, 目前的方案是在组件定义上提供 preFetch 函数,
服务端渲染时会主动查找挂载的部分, 调用进行数据抓取:
https://github.com/vuejs/vue-...
https://github.com/vuejs/vue-...

官方的例子当中 App 是带了 Vuex 跟 vue-router 的,
所以 preFetch 方案整个集成在这些库当中.
从实验看, 内部嵌套的 preFetch 是不会被调用的, 只能从路由开始,
同时中间要用到 Promise.all 合并请求, 脑补一下.

好吧我觉得这是一个相当简单粗暴的获取数据的办法,
但其实也很难解耦, 不然就要从路由直接推算数据才行,
主要觉得还是不够清晰, 限制挺多, 实际操作能犯错的地方不少.

性能影响

反正比不上模板引擎

编译后大致还能看到 Virtual DOM 的影子, 会有一些性能开销,
不过话说回来 Virtual DOM 本来就很慢, 能优化一点已经不容易了...

module.exports={render:function(){with(this) {
  return _h('li', {
    staticClass: "news-item"
  }, [_h('span', {
    staticClass: "score"
  }, [_s(item.score)]), " ", _h('span', {
    staticClass: "title"
  }, [(item.url) ? [_h('a', {
    attrs: {
      "href": item.url,
      "target": "_blank"
    }
  }, [_s(item.title)]), " ", _h('span', {
    staticClass: "host"
  }, ["(" + _s(_f("host")(item.url)) + ")"])] : [_h('router-link', {
    attrs: {
      "to": '/item/' + item.id
    }

另外 vm.runInNewContext 有潜在的性能问题,
http://stackoverflow.com/q/98...
不清楚用在生产环境是怎样, 我个人对此没有多少经验..

小结

越来越像 React...

Vue 2 算是把这么多内容整合在一起相当不容易,
不过服务端渲染 React 那么久了, 还是没普及开, 性能是大问题,
相比较而言, Vue 2 增加了 cache 机制, 这可以提高性能,
但是依赖数据时会带来启动 vm 开销, 要是代码量不小在么办?
具体效果还是要等正式发布后, 等有权威的评测...

此外服务端抓取数据的策略需要挖一挖, 找找更漂亮的策略,
我个人希望能更好地解耦, 梳理出更加清晰的依赖,
那样也可以适应更多的场景, 灵活地使用, 而不是限定死了这样用.
当然也是因为服务端渲染, 这个本来存在的问题显得更明确了.

查看原文

赞 22 收藏 95 评论 26

认证与成就

  • 获得 259 次点赞
  • 获得 9 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 7 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2014-10-08
个人主页被 3.1k 人浏览