shepherdwind

Node.js 中 sourcemap 使用问题总结

起源

Node 应用功能越来越复杂,很多业务都开始尝试使用 TypeScript 来开发。现在前端写的 JS 大部分是经过编译过程的,浏览器中通过 sourcemap 的使用,可以很好的解决源码和编译运行时代码差异的问题。

那么,在 Node 服务器环境应该如何使用 sourcemap 呢?最近在重新搭建一个完全基于 ts 的 Node.js 应用,所有的相关流程看起来都挺美好的,唯一的缺陷是报错信息错误信息指向的是 js 文件。我觉得应该探索下如何让 Node 支持 sourcemap 。

原理

对于 Node 而言,服务器 sourcemap 最大的价值在于错误信息有正确的错误堆栈,所以只要我们能够实现自定义错误堆栈信息就可以了。

恰好 v8 引擎有提供一个私有的 (Stack-Trace-API), 这个提供了让开发者自定义错误 stack 信息的能力。具体来说,开发者可以实现 Error 对象的 prepareStackTrace 方法,如果 Error 对象上定义了这个方法,那么每次错误信息都会经过 Error.prepareStackTrace 处理后返回。

Error.prepareStackTrace 方法可以拿到两个参数,错误基本信息和结构化错误堆栈,第二个参数是一个数组,通过这个数组可以拿到错误文件以及位置信息。最后基于这些信息重新返回一个字符串,这样就可以覆盖 Error 对象的 stack 属性了。

基本代码结构如下:

function prepareStackTrace(error, stack) {
  return error + stack.map(function(frame) {
    return '\n    at ' + wrapCallSite(frame);;
  }).join('');
}
Error.prepareStackTrace = prepareStackTrace;

wrapCallSite 方法里面可以通过分析源码,找到 sourceMap 然后返回正确的位置信息。

原理很简单,已经有一个 npm 包 source-map-support 封装好了相关功能。

这看起来已经很完美了。sourcemap 读取只在出现错误的时候才执行,所以这个功能不会有性能问题,在生成环境也可以开启

问题

Stack Trace API 看起来很美好,但现实场景总是更加复杂。我在引入 source-map-support 后,运行起来没什么问题,但在跑测试用例的时候,错误堆栈的位置信息完全不对。

这个问题排查了很久,最终定位到在 wrapCallSite 方法中拿到的 frame 对象返回的行号就是错误的,而这个获取行号的方法是 native code ,这个几乎没法调试了。我想,难道是 Node 的问题?要调试到 Node 源码么?

折腾了很久没有什么效果,就在我打算放弃的时候,我换了一个假设,会不会是某个包依赖影响的?然后我尝试依次删除跑用例时 require 的包,终于发现是因为 egg-bin 默认引入的 power-assert 导致的。

问题定位到后,解决就容易了。但解决这个问题得先讲讲 power-assert 是如何实现的。

power-assert 与 sourceMap

power-assert 作为一个断言库,最大的特色在于错误信息的报告是非常友好的,一张图可以很清晰看到区别

img

实现这样炫酷的报告是需要做一些特殊的处理,把测试用例的代码进行一次转换,举个例子

it('foo', function foo() {
  var a = 'foo';
  var b = 'b';
  assert(a === b);
});

经过 espower-source 处理后,变成了这样

it('foo', function foo() {
  var a = 'foo';
  var b = 'b';
  assert(expr(capture(capture(a, '/0/left') === capture(b, '/0/right'), '/0'), {
      content: 'assert(a === b)',
      filepath: 'bizLogger.test.ts',
      line: 107
  }));
})

注:上面的代码不是真实运行的代码,经过一些删减

对于 assert(a === b); 这样一个表达式,会通过 capture 捕获每一个运算过程的位置和值,最终通过 expr 运算。这样经过转换后,代码运行逻辑不变,但是异常发生的时候可以返回 assert 表达式中每一步的返回值。

我所遇到的问题也就是因为 power-assert 对代码进行了转换,最终异常抛出时,真实 js 异常位置信息是转换后的位置,这个位置自然是无法正确定位到源码位置了。

但只运行 power-assert ,不引用 source-map-support 的时候,错误行号还是对的。这是因为 espower-source 返回重新编译后的源码后,还同时对源码文件的 sourceMap 进行了重新转换。所以大部分情况,我们是无法感知到源码有经过重新编译。

运行简单流程图如下

img

解决问题

回到最初的问题,跑用例的时候行号不对了。power-assert 的影响在于两点

  1. 测试文件源码会被 power-assert 修改,增加一些信息收集代码
  2. power-assert 同时有引入 source-map-support 来对错误堆栈进行重新定位

当我在我自己的业务中也引入 source-map-support 来重新定位错误堆栈时,我所拿到的源码是被 power-assert 修改过的,所以这时候是无法重新定位到正确的 ts 位置了。

既然 power-assert 有引入 sourceMap ,那么是不是我关闭自己引入的 sourceMap 就可以了呢?理论上是应该如此的,但是因为 power-assert 对 sourceMap 文件不支持(inline sourcemap 是支持的),所以只能定位到 js 源码,无法定位到 ts 源码。

问题确定了,就可以自己动手解决了,我发了一个 mr 让 espower-source 支持 sourceMap 文件,最新版的 power-assert 已经可以正确定位到 ts 源码位置了。

另外,还有一个问题,正常情况 source-map-support 同时引入两遍,只要引入的文件路径一直,也是不会有问题的。但 power-assert 使用的还是老版本 source-map-support ,而且老版定位位置信息还是不够准确,这个也很好解决,升级依赖版本既可以。

这两个问题解决后,在自己的业务用引入 source-map-support 也没有问题了,power-assert 返回的错误堆栈也可以正确的指向 sourceMap 位置了。

总结

基于 V8 的 Stack Trace API 的使用,浏览器的 sourceMap 能力也可以应用到 Node 服务器场景下,使用 npm 包 source-map-support 就可以了。

有时候可能会遇到一些奇怪的错误行号的问题,这可能是某个依赖包对 js 进行了转换,毕竟这在前端太常见了,动不动就重新编译 js 源码。

webpack 热加载原理探索

前言

在使用 dora 作为本地 server 开发一个 React 组件的时候,默认使用了 hmr 插件。每次修改代码后页面直接更新,不需要手动 F5 ,感觉非常惊艳,这体验一旦用上后再也回不去了。

当时的 hot reload 实际上配置的是 live reload,也就是每次修改页面刷新。开发小组件每次更新也蛮快的,但如果一个应用应该使用上真正的 hot reload 才比较靠谱。

所谓的 hot reload(热加载) 就是每次修改某个 js 文件后,页面局部更新。

基于热加载这么一个功能,我们可以了解到 webpack 构建过程的基本原理。此外,还发现一个有趣的故事,redux 诞生自 React 热加载实现过程中。最后,针对现有 css 热加载实现的问题,我写了一个css-hot-loader

webpack 热加载基本原理

基本实现原理大致这样的,构建 bundle 的时候,加入一段 HMR runtime 的 js 和一段和服务沟通的 js 。文件修改会触发 webpack 重新构建,服务器通过向浏览器发送更新消息,浏览器通过 jsonp 拉取更新的模块文件,jsonp 回调触发模块热替换逻辑。官方文档有比较详细的描述,可以参考下。

本文更关注的是具体实现逻辑,而不是实现思路。热加载基本思路一般是很简单的,监听本地文件修改,然后服务器推送到客户端,执行更新即可。没有 webpack 的时候就有很多各种开发者工具、浏览器插件实现了类似功能。但从 module bundler 角度来实现的热加载,这个思路是非常神奇的,这比普通的 live reload 多走了一步,这一步成本应该蛮高的。那么 webpack 为何要实现热加载功能呢?这个看起来不是一个核心功能,一定是顺带实现了的吧。

来源

通过 webpack 作者 sokra 的分享来看,webpack 有两个核心概念

  • Code Splitting
  • Everything is a module

对于使用者而言,第二点会更加深刻,但我们通常对第一点 Code Splitting 没有体会。

所谓的 Code Splitting 不仅仅是把代码拆分成不同的模块,而是在代码中需要执行到的时候按需加载。这和纯前端 loader(比如 seajs、requirejs) 类似,但在 webpack 对模块设计上就区分了异步模块和同步模块,构建过程中自动构建成两个不同的 chunk 文件,异步模块按需加载。这一点突破是传统的 gulp 或者纯前端 loader 都无法做到的。

Code Splitting 还体现在对公共依赖的抽离(CommonsChunkPlugin),如果一个构成过程有多入口文件,这些入口的公共依赖可以单独打包成一个 chunk 。

webpack 通过的 require.ensure 来定义一个分离点require.ensure 在实际执行过程是触发了一个 jsonp 请求,这个请求回调后返回一个对象,这个对象包括了所有异步模块 id 与异步模块代码。举个例子

webpackJsonp([1], {
  113: '' // code of module 113
});

这实际上是通过 webpackJsonp 方法动态在模块集合中增加一些异步模块,这和热加载逻辑是类似的,唯一的区别在于:热加载是替换已有的模块。webpack 可以实现动态新增模块,那么动态替换模块也就轻而易举了。

实现

热加载实现主要分为几部分功能

  • 服务器构建、推送更新消息
  • 浏览器模块更新
  • 模块更新后页面渲染

构建

热加载是通过内置的 HotModuleReplacementPlugin 实现的,构建过程中热加载相关的逻辑都在这个插件中。这个插件主要处理两部分逻辑

  • 注入 HMR runtime 逻辑
  • 找到修改的模块,生成一个补丁 js 文件和更新描述 json 文件

HMR runtime 主要定义了 jsonp callback 方法,这个方法会触发模块更新,并且对模块新增一个 module.hot 相关 API ,这个 API 可以让开发者自定义页面更新逻辑。

重点说下构建过程中需要对更新的文件打包出的两个文件,这两个文件名规则定义在 WebpackOptionsDefaulter

this.set("output.hotUpdateChunkFilename", "[id].[hash].hot-update.js");
this.set("output.hotUpdateMainFilename", "[hash].hot-update.json");

这两个文件一个是说明更新了什么,另外一个是更新的模块代码。这两个文件生成逻辑

compilation.plugin("additional-chunk-assets", function() {
  this.modules.forEach(function(module) {
    // 对比 md5 ,标记有修改的模块
    module.hotUpdate = records.moduleHashs[identifier] !== hash;
  });
  // 更新内容对象
  var hotUpdateMainContent = {};

  // 找到更新的 js 模块
  Object.keys(records.chunkHashs).forEach(function(chunkId) {
    // 渲染更新的 js ,并且追加到 assets
    var source = hotUpdateChunkTemplate.render(...);
    this.assets[hotUpdateChunkFilename] = source;
    hotUpdateMainContent.c.push(chunkId);
  }, this);

  var source = new RawSource(JSON.stringify(hotUpdateMainContent));
  // assets 中增加 json 文件
  this.assets[hotUpdateMainFilename] = source;
});

上面代码简化了很多,具体过程是在构建 chunk 过程中,定义一个插件方法 additional-chunk-assets ,在这个方法里面通过 md5 对比 hash 修改,找到修改的模块,如果有发现有模块 md5 修改了,那么说明有更新,这时候通过 hotUpdateChunkTemplate.render 生成一份更新的 js 文件,也就是上面定义的 output.hotUpdateChunkFilename,并且在 assets 中追加一份 json 描述文件,说明更新了哪个模块以及更新的 hash。

上面的代码也可以发现 webpack 构建过程提供了很多丰富的接口,并且追加一个 output 文件是非常容易的事情,只需要在 assets 中 push 一个文件即可。找到修改的文件也很方便,首先构建前遍历所有的模块,记录所有模块文件内容 hash ,构建完成后,在一个个对比一遍,就可以找到更新的模块。

构建大致这样了,这里可能还涉及到 webpack 插件一些概念,可以参考看看 webpack 插件文档。

服务器推送

文件更新后,首先需要打包好新的补丁文件,还需要告诉浏览器文件修改了,可以拉代码了。

这一部分 webpack 自带了一个 dev-server。当开启热加载的时候,webpack-dev-server 会响应客户端发起的 EventStream 请求,然后保持请求不断开。这样服务器就可以在有更新的时候直接把结果 push 到浏览器。

服务器推送部分比较简单,构建一个 node 的 Server-Sent Events 服务器只需要几行代码,这里有一个例子

一次完成的构建流程大概是这样的

Snip20170205_1.png

上述步骤完成,热加载前两步就 ok 了。每次文件修改,浏览器模块代码也更新了。但是就这样而言,模块更新还算不上完整的热加载,因为模块更新了,页面还没更新。前面提到构建过程中会在入口文件中加入一段 HRM runtime 代码,其中就有加上 module.hot 相关 API 。这个 API 就是提供给开发者自定义页面更新用的。

下面,我们进入热加载最后一步,页面局部更新。

React 热加载

React 热加载现在主要有两个工具包来实现

一个是 webpack 的 loader ,一个是 babel 插件,都是 redux 作者 Gaearon 开发的。现在两个都非常火,大家经常问这两个具体有啥区别,最近 Gaearon 准备把这两个都废弃,重新开发了 react-hot-loader 3.0 。其中曲折历程,可以看看作者写的文章 Hot Reloading in React

在研究 react-hot-loader 实现过程中,我发现一个神奇的故事:

I wrote Redux while working on my React Europe talk called “Hot Reloading with Time Travel”.

Redux 诞生自作者研究 React 热加载实现过程中。Gaearon 首先实现了 React 的热加载,然后发现当时使用的 flux 无法热加载,因为 flux 有一个全局的 store ,action 都是通过消息来沟通,当这个对象替换的时候还需要重新绑定事件(flux还在 componentDidMount里面绑定,无法替换)。要实现热加载,就需要对 flux 进行改造,然后一步步删除 flux 中多余的部分,redux(reducer + flux) 就诞生了。redux 里面 reducer 、action 都是一个个纯函数,所以做替换是非常简单的。

对于基于 React 的应用,实现 React 热加载的基本思路分为两种

  • 直接基于 module.hot API
  • 对每个组件进行一次包裹,组件更新后替换原有组件原型上的 render 方法和其他方法

第一种方案可以这样实现

var App = require('./App')

// Render the root component normally
var rootEl = document.getElementById('root')
ReactDOM.render(<App />, rootEl)

if (module.hot) {
  // Whenever a new version of App.js is available
  module.hot.accept('./App', function () {
    // Require the new version and render it instead
    var NextApp = require('./App')
    ReactDOM.render(<NextApp />, rootEl)
  })
}

module.hot 是 webpack 在构建的时候给每个模块都加上的对象。通过 accept 可以接受这个文件以及相关依赖的更新,然后在回调函数里面重新 require 一遍获得新的模块对象,执行 render 。

这相当于整个页面重新渲染了,但这种会方案无法保存 React 组件的状态。如果组件都是纯 render 方法,这样基本没问题。

第二种方案 react-hot-loader 也需要重新执行 render ,只不过区别在于重新 render 的时候组件对象引用并没有修改,但每个组件都包裹了一层代理组件,代理过程会替换 render 方法。react-hot-loader 这套方案涉及到很多 React 的私有 API ,而且包裹代理对象过程有时候会失败,所以 Gaearon 发布两套方案,还在重构第三套,具体探索可以看看这篇文章Hot Reloading in React

CSS hot loader

js 热加载基本上是通过自动更新组件,重新渲染页面两个步骤完成了。还有一个比较重要的是 css 热加载,webpack 官方提供的方案是 style-loader

一般的对 css 处理都是通过 extract-text-webpack-plugin 插件把 css 抽离到单独 css 文件中。但 extract-text-webpack-plugin 是不支持热加载的,所以 css 热加载需要两个步骤:

  • 开发环境关闭 extract-text-webpack-plugin
  • 开启 style-loader 插件

style-loader 实际上就是通过 js 创建一个 style 标签,然后注入内联的 css 。因为 css 是内联,并且通过 js 注入,那么页面刷新的时候一开始是没有任何 css 的,这种体验会非常差,闪一下然后页面重新渲染成功。

为什么 extract-text-webpack-plugin 就不能支持热加载呢? 这个问题很多人都遇到过 extract-text-webpack-plugin#30,这个 issue 还有人提 mr 直接支持热加载。

参考 react-hot-loader 来实现一个 css-hot-loader 也不难。每次热加载都是一个 js 文件的修改,每个 css 文件在 webpack 中也是一个 js 模块,那么只需要在这个 css 文件对应的模块里面加一段代码就可以实现 css 文件的更新了。

    if (module.hot) {
      const cssReload = require('./hotModuleReplacement')});
      module.hot.dispose(cssReload);
      module.hot.accept(undefined, cssReload);
    }

上面每个 css 对应的模块都会接受自身的修改时间,并且执行一次 cssReload 函数,在 cssReload 函数里面会找到需要修改的 css 外链标签,加一个时间戳让浏览器重新请求这个 css 文件,那么页面样式就更新了。

webpack 中扩展功能有两种方式

  • loader
  • plugin

一个 loader 是对模块进行处理,比如 css 处理过程可以用这样来描述

style-loader!css-loader!less

对每个 css 模块会依次执行 less、css-loader、style-loader 处理,每个 loader 处理后的结果作为下一个 loader 的输入字符串,有点像 linux 的管道,只是方向是反的。

插件处理的是 chunk ,loader 处理完成后,可以得到一个依赖树,每个模块都有一个处理结果的描述。在插件里面可以对整个 entry 输出的内容进行一些处理,比如热加载过程中增加 HRM runtime 脚本,对所有的 css 抽离到单独的静态资源中。

css-hot-loader 所做的是在 css 模块中注入一段脚本,所以是一个 loader ,并且是第一个 loader ,这样可以保证代码不会被 extract-text-webpack-plugin 抽出来。

总结

热加载只是开发体验的一小步提升,但这个技术背后包含了很多技术的铺垫,慢慢一路发展过来,最终达到让人耳目一新Hot Reloading with Time Travel

webpack 诞生于对 Code Splitting 特性的实现,从 webmake 重写为 webpack 。redux 诞生于 React 热加载探索过程中。可见对一项看起来不起眼的技术的深入探索是非常值得的,也许某个伟大的开源作品就在探索中诞生了。

如何实现一个mvvm组件

去年,因为业务需要,实现了一个mvvm组件,bidi。在此,记录一下组件实现的过程与经验。

来源

mvvm是Model-View-ViewModel,主要在于双向绑定。这个概念很早就有了,我一直没有具体研究过这个东西,直到有一天,我遇到一个这样的需求:

needs

业务场景大致是这样的,在淘宝购物,如果需要退款,首先需要填一个表单,这个表单要求,你说明你的投诉原因,投诉原因下面又分一个二级:具体原因。具体原因决定赔偿金额校验,这些区别在于,某些原因是可以申请赔偿运费的,某些原因是不可以的,这个赔偿金额是由原因决定的。

这是一个比较简单地场景,还有更复杂的表单,上面的原因可能有三级。

这个项目,改造的是非常古老淘宝退款项目,累计了8年,现在终于重写了。我当时就想,这么多逻辑规则,需要有一种通用的解决方案。于是,我想到了所谓的mvvm,实在太适合了。mvvm天生适合于复杂的表单逻辑,处理表单联动,主要也就是在于,监控表单改变,然后改变其他表单元素。这样的联动实际上,就是V -> M 然后 M -> V的过程,视图改变,对应的JS改变,改变其他表单视图元素。

约定

然后,我开始研究现有的几种mvvm组件,主要看了knockoutjs, emberjs, angularjs。首先,我看这些已有的组件我能不能直接用,当时做的是pc端业务,不能放弃IE6,emberjs和angularjs都不能用了,只剩下knockout,但是我也不想用jQuery,在淘宝用的都是KISSY。所以,我就想,那么就自己实现一个吧。

然后,我主要关注这些已有的组件,他们的使用方式。至于他们怎么实现的,我不是很关注。mvvm的实现,比较重要的是一个东西是view怎么写,在我看来,主要是有两种不同的形式。

  1. html文档,knockout和angular都是这样的
  2. JS模板,比如handlebars,ember就是基于handlebars来实现的

最后,我使用了一种最简单地方案,把ember和knockout实现的结合。定义了几个基本原则,用这种约定的方式来写mvvm模板的语法:

  1. 数据和dom的绑定,通过写一个JS模板语法,{{watch "action: model.var"}}的方式来绑定
  2. 每个绑定占据一个dom节点
  3. view通过XTemplate模板来写

下面我来解释一下,整个实现过程:

模板

在我做的bidi中,用户首先写的是一段模板:

 <div class="bidi-viewer" data-view="user">
    <script type="text/xtemplate">
      <p>hello, I am <strong {{watch  "text: fullName"}}></strong></p>
      <p>hello, I am <strong {{watch "text: firstName"}}></strong></p>
      First Name: <input {{watch "value: firstName"}}/><br>
      Last Name: <input {{watch "value: lastName"}}/>
      <button {{watch "click: capitalizeLastName"}}>Go caps</button>
    </script>
  </div>

这里描述了一段view视图规则,首先,有一个最完成的容器.bidi-viewer,这个容器还通过data-view定义了view所对应的模型。里面是一段模板,包裹在script里面,在js里面,会自动把模板拿出来,和数据放到一起,然后把运算得到的html放到当前视图中去。

然后,仔细看下这里的绑定是怎么写的:

<strong {{watch  "text: fullName"}}></strong>

这里fullName绑定在strong上了,{{watch}}是XTemplate模板的一个自定义函数,后面的是参数。这里有几个细节需要注意,这里的绑定fullName,同时存在strong元素,也许大家会觉得{{fullName}}这样写,不是要有好得多么?angular是这么实现,非常牛叉。但是这样搞的问题在于,绑定一个变量,你的知道这个变量在什么地方,这样当JS这个变量改变的时候,你知道去改dom中哪个元素。

如果angular那样,你就不能简单的找到这个{{fullName}}具体对应的dom节点了。你得找到这个变量所在的上下文,找到最上层的元素。这样的查找还是非常费事情,所以我选择了最简单的方式,直接一个元素绑定一个变量,通过{{watch}}这样的语法,然后这个自定义函数,返回一个字符串id=bidi-{uid},这样就非常简单地解决了把view和model关联起来的任务了。

当然,这也意味着,id是保留属性,每个绑定都不能有id,这个是通过bidi自动生成的。

这个问题,把view和model联系起来,是mvvm里面,最关健的一步了。

函数

前面,主要谈了view怎么写,继续看js怎么写。

 KISSY.use('gallery/bidi/1.0/index', function (S, Bidi) {

      Bidi.xbind('user', {
        firstName: 'Song',
        lastName: 'Eward',
        fullName: function(){
          return this.get('firstName') + ' ' + this.get('lastName');
        }
      }, {
        capitalizeLastName: function(){
          var lastName = this.get('lastName').toUpperCase();
          this.set('lastName', lastName);
        }
      });

      Bidi.init();
})

对于用户而言,使用this.get()和this.set()来赋值。这里有一个问题,就是fullName是一个函数,fullName如何和firstName和lastName关联起来的。

回到前面的模板,{{watch "text: fullName"}} 这一个运行的时候,我得去取fullName的值,这个时候,会运行执行fullName这个函数,现在,我只需要在fullName执行之前记录一个空数组,然后在fullName执行过程中,所有通过get的变量都记录在那个数组里面就可以了,但fullName执行完毕,我看下这个数组,就知道fullName和哪些变量有关了。

这种方式加缪提到过,其实看起来很炫,其实实现很挫的方式。这里种方式,有一个大坑,如果出现循环关联,可能就悲剧了。另外,这种方式,函数都必须是同步的,如果在模板中处理异步的,就没法搞了。

实现过程

现在,可以把整个流程串起来了。

image

view通过innerHTML获得模板,model通过toJSON方法,获得数据,两者结合,通过XTemplate解析模板。遇到{{watch}}自定义函数的时候,执行绑定,绑定函数同时返回一个dom的id。模板解析后,这些id成为视图上一个个dom节点的id,完成dom渲染,前面的绑定会和dom事件进行绑定。

在dom区域,有一个#bidi-{uid2}的元素,出发,构成一个V - M - V的绑定循环。bidi-{uid2}元素,是一个input,change事件触发,input的value改变,watch.value方法,监听了dom的change事件,在内部执行this.set('var', value),set方法,触发change:var事件,而watch.text方法,监听了来model内部的change事件,watch.text会修改#bidi-{udi1}元素的innerHTML,这样,一个循环完成。

表单元素

前面是一个简单例子,基本的原理,就是这个图片所说的了。实际情况,我得解决业务需求,就前面那种简单地MVVM,还是没法解决表单联动的。这里有一个问题,通常情况,我们是一个变量和一个dom对应,但是对于表单,出了单个变量之外,还有一种集合形式的,这就是radio、checkbox。所以,为了解决这种问题,我把一组radio看做一个变量,和Model对应。这样做,还得定义一个约定。

form

有了这样的约定,就可以这么描述一个联动关系了

    <select name="key1" {{watch "select: problem.defaultValue">
      {{#each problem.values}}
        <option value="{{value}}">{{text}}</option>
      {{/each}}
    </select>
    <div>
      {{#watch "linkage: reasons.values: problem" "radio: reasons.defaultValue"}}
        <label class="radio-inline"> <input type="radio" value="{{value}}"/>{{text}} </label>
      {{/watch}}
    </div>

优点

优势还是蛮多的,最方便的是,处理开发各种诡异的需求,简单多了。举个例子,开发说,二级原因被选中的时候,需要发一个异步请求,然后获得三级原因,然后现实三级原因。比如,他们经常说,哎呀,这个二级原因的文本也得给我,放隐藏域里吧。这样的需求,对于我而言,不是很复杂,稍微改下模板,不用动其他业务逻辑。

缺点

缺陷同样蛮多的,最开始做这个的时候,我觉得还慢不错的。但是,当我听到react的分享后,我瞬间觉得这个东东弱爆了。下面讲讲问题:

  1. 一个变量绑定一个dom的方式,看起来很简单,也很美好。但现实要骨感得多,比如,一个表单元素,当某个二级原因选中的时候,这一行表单元素需要隐藏,实现这个,要做的涉及到很多改动,第一,这个表单元素需要隐藏起来,同时表单本身需要被disabled,不能直接被删掉,也许什么时候,这个元素又要出来了。也就是,一个变量的修改,可能涉及到很多dom的修改,这直接去修改dom,还是比较浪费的。

  2. 与其他JS组件沟通的问题,MVVM通常直接与dom绑定,但是有些其他组件,比如表单元素模拟的组件,也要处理表单,经过模拟的表单元素,你甚至看不到表单元素了,这样的问题还是不好解决的。

  3. 模块粒度问题,bidi没法处理view嵌套的情况。这个问题,其实不算大问题,如果能够处理好view的嵌套,就可以做出webcompement了,angular在往这个方向发展。但是做得还是不够好,我觉得上面我遇到的问题,react都能解决。

最好讲讲react,当我听到react杀手锏是,自己定义了一套虚拟的dom,这个dom和html文档里面的dom进行沟通。JS本身不直接接触dom,而是虚拟的dom。这样的好处,可以随时随地修改dom,但是这些操作不会直接对dom进行改动,统一通过visual dom映射到dom中。这其实在dom操作和js中间,搭建了一个桥,所有的操作首先放到桥上,然后整理一下,用最快的方式,在dom中操作。

另外,一个大多数mvvm或者其他web compemnt组件所做不到的是,visual dom是JS实现的,所以,react可以在服务器端跑,这样一来,页面不仅仅可以同时在前后端渲染了。这是非常非常好的特性了。

再谈vim

一年前谈到vim,现在过去了这么久,开始接触一些其他编辑器,有了新的看法,在此记录一下。

vim有相对于其他编辑器的优势,同样,有很多缺点。我觉得说vim要比ide好,这是不对的,vim好用,其他编辑器同样好用,甚至更好用。

我感觉,vim适合开发前端,但肯定不一定适合其他语言,比如java。

下面说说对于这个问题的感受:

第一,vim对硬件没有要求

最初使用vim,只是因为我的第一台个人电脑(一个10寸的上网本),能够跑起来的编辑器只有vim,没有办法,只能硬着头皮作下去。然后就这样,4年过去了,现在用的mac air,同样是最低配的,chrome + eclipse基本上就很卡很卡了,vim还是完全没有问题。也许对于很多人而言,硬件不是问题,但是vim让你对硬件没有要求,这点很重要,有时候对硬件的需求像是永远填补不满的欲望。

俗话说,穷人家的孩子早当家。能够适应恶劣的硬件环境,从长远反正来看,是一种修行的必须经历的过程。

第二,vimscript

用得越多,我越觉得vimscript是vim最好的地方。

如果说到语言的比较,大多数程序员会撸起衣袖,相互拉扯,磨刀霍霍的气势。每个人觉得自己用的语言是最完美的,但是如果说vimscript是垃圾语言,没有人会反对。

确实,从语言角度来看,vimscript太弱了。但是,在jQuery出现之前,在javasciprt the good parts出现之前,大家同样觉得javascript是垃圾语言。vimscript作为编辑器内置的语言,是很好用的。用一句话来说,就是vim is vimscript。

我觉得一门语言是否好,要看它是否好用,是否适合它所运行的环境。vim里的所有操作,都是vimscript。这一点很重要,很多人觉得要记住那么多的快捷键,非常麻烦,实际上,你可以把操作vim看做是编程过程。因为,操作vim,就是写vimscript的过程,作为程序员,学习一门语言应该不是难事。

把操作vim当初编程,我们首先得学习这门语言的基础只是,在vim就是vim配置文件,还有基本的操作,模式等。学会这些基本没什么问题,有vimtutor。后面真正用上vim来写代码,你会有一种感觉,关于如何使用vim的知识,是永远学不完的。

最近,经常有人拿vim和sublime比较,sublime学到了vim的轻巧,同样有IDE的简单。相比而言,vim似乎要繁琐,无聊得多。实际上,这就是vim的风格,你需要花很多经历去学习,去折腾,受到挫折。其他编辑器强调的是一体化,功能定制好,sublime上手只需要一天,你不需要去折腾什么,一切都很好了,IDE也同样如此。

两者的主要区别的,vim你需要会写程序,而后者,只需要学会配置文件就行了,甚至配置都是可视化的。有人说,sublime之类的,我们关注的是写代码本身,而不需要关注编辑器,用vim,你得同时操作一门语言,一些繁琐毫无意义得快捷键。

从我的角度而言,操作vim的过程和写代码本身是一种事情,作为程序员,有一种程序可以用来操作正在写的代码,这不是很有趣的事情吧。在程序员的世界了,程序拥有最高的自由,在vim里,写程序的过程是自由的。那些其他人看起来无意义的快捷键,对于我而言是有意义的。就像代码对于非程序员而言就像天书一样,但程序员会觉得很美。

总结一下:

现代的编辑器,都非常优秀,有时候在折腾了一整天的vim插件后,我非常沮丧,觉得vim没救了。后来我慢慢的明白了,试图让vim做所有事情,是不现实的。慢慢的,我也不再推荐其人用vim了,这种折腾过程,有时候还是挺痛苦的,自由的代价,可能不是所有人都喜欢这样。如果有足够的时间,比如是大学生,还是很值得体验一下vim的。

一旦能够享受vim给的自由,你会爱不释手的。其他编辑器虽然很好,很舒服,但那都是别人的编辑器,你永远驯服不了。这是我对这个问题对回答,vim的优势所在。

最后推荐两个资料

  1. vimscript教程。非常优秀的教程,打算深入学习vim的可以看看,vim文档虽然很详细,但是太零散了,这本书系统的讲解vimscript。另外,非常喜欢这个书名,Learn Vimscript the hard way,the hard way对于vim来说,太适合了。
  2. vimcasts.org,一个关于vim的播客,分享一些很多很玄很酷的技巧,推荐被vim折腾过的人看看,可以大大增加你继续学习下去的信心。

改过的bug

最近改了两个bug,记录一下。

第一个,表单提交问题

在售后页面,表单有比较复杂的,逻辑,包括模拟select,select选择提示效果,uploader,复杂校验,表单元素之间逻辑交互。

问题描述

上周五,开发说,线上有很多IE8用户数据没有通过前端校验,直接提交到后端,导致很多报错。我研究了很久,最后,发现问题是,校验失效了。

分析

直接原因是,整个校验还是有,但是校验失败的情况下,表单还是提交了。在chrome,因为剑平的Auth是基于html5校验的,所以,表单提交动作被拦截了。造成似乎只有IE8有这个问题的假象,从这点看,这次的问题因为剑平Auth选择使用html5标准,从而问题较少了不少。

继续分析源码,发现问题是以前整个表单的提交动作是由js来完成的,通过submit()函数提交。当Auth的校验通过之后,会提交表单。而现在这个页面的点击按钮由原来的div,变成了button,在一个表单中,button默认是submit类型的。

结论

提交按钮由div变成button了,导致页面直接被提交了。我离开把这个button改成div,让开发马上发模板。后来听说,这个改动是玉门改的,当时有个日常需求需要的。不过想想,div作为提交的按钮,确实容易引起误解,改成button,也蛮正常的,只是,这个页面的提交逻辑封装在一个gallery组件里。新接手的时候,难免不清楚这样的逻辑。


第二个,黑盒问题

还是售后,要求淘宝小二介入的页面。

问题描述

页面大致这样子的

taobao in

退款原因是一个下拉框,点击修改,下拉框显示。下拉框是模拟原始的下拉框,有一个真正的select元素,但是隐藏在模拟select框下。现在问题是,点击修改的时候,变成下面样子了

after change

多了一个签收人,这个地方,逻辑来源于,选择下拉框的时候,会出现二级选择框(实际上,这是第三级)。开发说,这个签收人是下拉框第一项元素对应的二级radio,但是现在不应该出现的情况出现了。

分析过程

首先断点一下,发现点击修改的时候,下拉框首先选择了第一项,然后又变回去了。这个过程很诡异,不清楚是什么导致了下拉框select的值被修改的。

问题很麻烦,因为这个页面使用MVVM(bidi)实现的,下拉框和JS变量绑定在一起,下拉框dom的修改,或者js变量的修改都会直接体现在dom的改变上。js变量在什么地方被修改了,这个也不是很清楚,任何引用那个变量的地方,都可能会修改到那个值。

Bidi的调试,一直是个非常头痛的问题,上次玉门发现一个bug,找我看了好久,我最后凭感觉找到一个隐藏的bug。这里涉及到Bidi里面的model和view(dom),另外还有剑平的模拟选择框,三个对象都可以能有问题。select的改变犹如一个黑盒,时间绑定一方面完全解绑了发布事件者和事件订阅之间的联系,同时这种解耦导致调试非常麻烦,你完全不知道是在什么情况下处罚了某一事件。

这时候,我想到了bidi曾经遇到一个问题,Firefox的Object下有一个方法watch,这个watch是火狐独有的方法,基本没什么用。不过能够监控变量被修改,这个可以之间从前面的黑盒中找到问题发生的现场。

有了这个思路,就好办了。首先,点击修改后“退款原因”被修改了,那么一定会映射到对应的js变量reasons.defaultValue上,只需要监控谁在修改了reasons.defaultValue。然后,写下watch函数的是实现:

  function watch(obj, property){

    // 通过闭包,存贮value
    var nowVal = obj[property];

    return function(){

      Object.defineProperty(obj, property, {
        get: function(){
          return nowVal;
        },
        set: function(v){
          // 设置一个断点,查看变量被修改的场景
          debugger
          nowVal = v;
          return v;
        }
      });

    }

  }
  watch(reasons, 'defaultValue')();

结论

修改的时候有了debugger,很快发现,点击修改按钮之后,reasons.defaultValue确实被改变了,根据函数调用过程,直接找到问题症结所在

problem

图片中,有一部分被挡住了。这是模拟选择框完成的事情,触发afterValueChange事件的时候,会执行操作把选择框的值修改。实际上,是模拟选择框的值回写到原始的select上,然后bidi会根据原生select的改变执行DOM到bidi数据的绑定。

bidi都是直接和原始表单元素打交道,涉及到模拟选择框,中间就有点绕了。

整个过程是,点击修改按钮,然后会显示模拟选择框,问题是,显示模拟选择框的时候,模拟选择框触发了afterValueChange事件,上面图片看到的,newVal是null,这种情况下,下拉框的value被设置为空,这个时候,浏览器默认选中了第一个。然后原生select被修改,触发change事件,change事件导致bidi里面的绑定生效,初上上面那个多余的收获签收人。

最后修改的方案还好,那个valueChange事件不应该触发的,不过那个和KISSY自身的select组件有关,也不好改动。最后,我修改了下,让bidi在显示模拟选择框过程中,禁止双向绑定功能200ms,完整切断上面整个流程,问题基本解决了。这还是一个大坑,在这里记录一下,一来,后面维护的也可以有个参照。另外,我感觉watch的方式用来调试还是非常给力的,终于解决了bidi最麻烦的不可调式的问题。

[翻译]内容安全策略(CSP)

原文:Content Security Policy
作者:josh

说明 :本文是Github全站部署CSP策略过程总结的经验。总体上,CSP安全策略很大程度上解决了JS非同源策略引起的问题,通过JS加载源白名单机制,能够很好的保护用户。

作为前端,CSP策略所提倡的方案,都是JS和CSS规范的方案,CSP所做的是完全抛弃不规范内联JS写法,只要我们稍微注意就能够做到了,而且这也是一个专业的前端应该做到的。

另外,框架开发者也应该考虑CSP兼容的问题了,测试过程很简单

<?php
//header('Content-Security-Policy: default-src *; report-uri /csp/recode.php');
header("X-Frame-Options: SAMEORIGIN");
header("Content-Security-Policy: default-src *; script-src 'self' g.tbcdn.cn");
?>

全文翻译如下:

我们新部署了一个叫内容安全策略(CSP)的策略。对于用户,这能够更好地保护你的账户不被XSS攻击。需要注意的是,这可能引起一些浏览器插件或者书签的问题(译注:浏览器插件使用inline script功能的会被禁止,有一些书签本身是javascript伪协议地址)。

Content Security Policy是一种新的HTTP头域,用于防范XSS攻击来加强网络安全性。CSP通过阻止inline script执行和限制被允许加载的脚本域名来做到(安全防护)。这不意味着你可以不用在服务端过滤用户输入的数据了,而是,当你的过滤器被绕过了,CSP可以给你提供最后一层防御。

准备好你的应用服务器

CSP header

在Rails应用激活CSP是轻松的,它只是一句简单的头信息。你不需要引入任何其他库;设置一个before filter就够了(译注:before filter应该是请求处理前置的hook)。

before_filter :set_csp

def set_csp
  response.headers['Content-Security-Policy'] = "default-src *; script-src https://assets.example.com; style-src https://assets.example.com"
end

这个头信息定义可以允许加载内容的白名单url路径。script-scrstyle-src指令都是用于配置我们静态资源的域名。然后,除了我们自己的域名之外的脚本都无法加载。最后,default-src是为所有没有定义的配置项设置默认值。比如,image-srcmedia-src可以用于限定图片、视频和音频的加载地址。

如果你想让更多地浏览器支持,可以为X-Content-Security-PolicyX-WebKit-CPS设置同样的值。不考虑向后兼容,你只需要设置正确处理标准的Content-Security-Policy(译注:最新的chrome和firefox都已经支持标准协议)。

由于CSP实现趋于成熟,这在将来也许会成为Rails自身开箱即用的功能。

开启CSP策略很容易,真正的挑战在于让你应用准备好面对(CSP带来的改变)。

内联脚本

除非设置了unsafe-inline,所有的内联脚本标签都会被拦截。这是实现你想要的XSS防御的主要手段。

大部分内嵌的脚本都是用来配置页面属性的。

<script type="text/javascript">
GitHub.user = 'josh'
GitHub.repo = 'rails'
GitHub.branch = 'master'
</script>

更好地方式是以下代码展示的,把配置信息放在data-*的属性中。

<div data-user="josh" data-repo="rails" data-branch="master">
</div>

内联事件绑定

和内联脚本标签一样,内联的事件绑定也过时了。

如果你在2008年之后写过JS,你大概会使用一种不那么直接的方式绑定事件。但是在你的代码库里面,可能还隐藏着一些内联的事件绑定。

<a href="" onclick="handleClick();"></a>
<a href="javascript:handleClick();"></a>

一直到Rails 3,Rails自带的模板还会通过link_toform_tag生成一些内联的事件绑定。

<%= link_to "Delete", "/", :confirm => "Are you sure?" %>

将会输出

<a href="/" data-confirm="Are you sure?">Delete</a>

你需要使用UJS引擎,比如jquery-ujs或者rails-behaviors,通过配置data属性来实所有的效果。

Eval

eval()的使用是被禁止的,除非配置了unsafe-eval(译注:eval包括其他类似的函数setTimeout, Function).

尽管你也许没有在代码里直接使用eval(),但你可能使用了某种浏览器端的模板库。通常的,字符串模板在浏览器端被解析编译为JS函数,通过eval的方式执行,这样开发更方便。比如@jeresigclassic micro-templating script。更好地方式,应该使用在服务端预编译模板库,比如sstephenson/ruby-ejs.

另一个中招的是RJS模板,它通过服务端输出JavaScript。jQuery和Prototype都需要使用eval()来运行RJS模板通过异步请求返回的代码。很不幸这种方式无法正常工作...(译注:这一段关于rjs模板,没看明白,省略了,之和ruby开发者相关)。

行内CSS

除非style-src定义了unsafe-inline,否则所有的html中style属性都是无效的。

最常用于控制元素加载时隐藏的方式如下

<div class="tab"></div>
<div class="tab" style="display:none"></div>
<div class="tab" style="display:none"></div>

更好的方式是使用CSS状态的class

<div class="tab selected"></div>
<div class="tab"></div>
<div class="tab"></div>
.tab { display: none }
.tab.selected { display: block }

尽管如此,使用此特性还需要谨慎。像jQuery或者Modernizr之类的类库,执行一系列浏览器特性侦探的时候,会在页面注入一些自定义的CSS,这将触发CSP警报而被阻止。暂时,大部分应用都需要禁止这个特性。

缺点

书签功能

CSP文档所定义的,浏览器书签功能应该不受CSP影响.

执行CSP策略应该不干扰用户自定义的脚本比如第三方插件和JavsScript书签的功能

http://www.w3.org/TR/CSP/#processing-model

每次客户端要执行javascript URI中的脚本时,拒绝执行这些脚本. (客户端应该可以执行书签链接中的脚本,即使执行CSP策略的情况.)

但是,没有浏览器正确处理这种情况。都违背CSP定义,阻止书签功能。

尽管这有点让人沮丧,你也可以在特定条件下禁止Firefox执行CSP。打开about:config,设置security.csp.enablefalse.

扩展

和书签一样,CSP不支持浏览器插件与页面交互。不过实际上并不总是如此。具体来说,Chrome和Safari,扩展是用JS实现的,通常会修改当前页面,这有可能触发CSP异常。

Chrome插件LastPass就有CSP兼容问题,因为它试图在页面注入<script>标签。我们已经和LastPass开发者报告了这个问题。

CSSOM限制

内联CSS是被禁用的,这是CSP默认限制的一部分,除非定义style-src指令为unsafe-inline。这一次,只有Chrome正确的实现这种限制。

你依然可以通过CSSOM动态修改CSS样式。

客户端同样不能阻止CSS对象模型对样式的控制

http://www.w3.org/TR/CSP/#style-src

这是非常必要的,比如你希望在你的网站上实现一个需要动态绝对定位的自定义提示条效果(译注:提示条需要改变left和top值)。

对于行内CSS序列化实现还是有一些bug(译注:CSS规则序列化的结果输出为cssText,序列化过程指cssText转换为CSSOM的解析过程).

一个bug具体的例子是克隆一个有CSS属性元素(译注:这个bug已经修复了).

var el = document.createElement('div');
el.style.display = 'none'
el.cloneNode(true);
> Refused to apply inline style because it violates the following Content Security Policy directive: "style-src http://localhost".

同时,正如前面提到过得,像jQuery和Modernizr这种类库执行一些浏览器特性侦探的时候,在页面注入一些自定义的样式用于测试,可能会引起异常。但愿这些问题能够被这些组件自己去解决。

报告

CSP报告的特性是很巧妙的想法(译注:CSP有一个字段report-uri,被CSP规则拦截的时候,会向reqort-uri发送一个请求,报告错误信息)。如果一个攻击者在你的网站上发现一个XSS绕过漏洞,被攻击者访问页面的时候,在CSP开启的情况下XSS攻击会被报告到服务器。这在一定程度上可以作为一个XSS攻击监控系统。

但是,由于当前书签和浏览器扩展的一些问题,CSP警告的误报会淹没你的后端日志。报告的payload信息也可能很模糊,这取决于浏览器。如果幸运的话,你能获得攻击触发对应的压缩js文件的行数。通常也很难分辨错误是发生在你的JS还是浏览器插件注入的代码。这导致误报无法被过滤。

总结

尽管有一些问题,我们还是选择部署CSP策略。希望最新的CSP 1.1 draft提案能够解决一些问题。

最后,特别感谢来自谷歌的mikewest给与我们的帮助。

How to realize velocity template interpreters

前言

承玉曾经写过一篇文章构建前端DSL,文中提到:

从本质上看模板也是一个微型语言,因此可以从 DSL 的角度着手,使用工具快速构建一个适合于特定前端框架的模板引擎。

本文讨论的话题和承玉的差不多,相信大家都知道coffeescript,handlerbars。承玉的DSL和handlerbars类似,我完成了一个模板语言velocity的解析,更接近coffeescript的编译。在此,与大家分享一些经验,如果你也希望知道coffeescript语法解析如何完成的,那么,这片文章应该对你有所帮助。

让我们回顾一下2010年D2的时候,Hedger介绍了Closure Compiler,老赵的jscex,他们有一个共同点,都是对js进行编译,让js运行更快或者提供一起额外的功能。编译这么一个似乎和JavaScript没有关系的话题,却逐渐被越来越多的人提起。

本文主要介绍如何用js写一个编译器,这看起来似乎很高级,实际上,编译原理很复杂,写一个编译器却不怎么难,在做这个模板编译之前,我个人对于编译原理完全不知道的,因为看到coffeescript语法是Jison生成的,然后尝试了一下。写一个编译器,其实就是把一些语法规则翻译成计算机能够理解的结构,计算机所能理解语法规则有专门的描述语言,Yacc + Lex。IBM上有文章如此描述:

Lex 和 Yacc 是 UNIX 两个非常重要的、功能强大的工具。事实上,如果你熟练掌握Lex 和 Yacc 的话,它们的强大功能使创建 FORTRAN 和 C 的编译器如同儿戏。

Yacc + Lex的一个实现是Bison,09年Zach Carter为了研究编译原理课程,用js完成了Bison的实现Jison, 承玉的kison类似。故事就讲到这里,什么是Yacc,Lex,Bison,Jison,Kison,都不重要,重要的是,这些技术使得我们可以使用简单的方式完成复杂的字符串解析(比如编译)任务。现在我们要来实现一个编译器了,看完就知道这一切了。

在此声明,对于编译的理解仅限于个人理解,如有不对之处,欢迎指正。

Lex & Yacc

Lex和Yacc主要用于解决编译中的第一个问题,源文件从字符串变得有意义(结构化数据)。这个过程,又分为两个步骤:

  1. 源文件拆分成各种标志(tokens) Lex
  2. 构造数据结构 Yacc

学习英语的时候,我们都会遇到语法问题,对于陌生的语言,需要知道其语法规则,计算机语法规则与自然语言类似,只是自然语言是与上下文有关的语言,比起计算机语言复杂得多。与上下文无关,其实就是语言的符号意义是确定的。扯远了,举个例子,一个正常的英语句子:

What you name?

回到英文课堂,老师会说,句子是由主语+谓语+宾语构成,这个句子构成的元素是,主语you,谓语what,宾语name,谓语动词前置构成疑问句,疑问句结束用问好。这样的一个语法规则,让计算机理解,需要依据上面的两个步骤:

  1. 识别单词,也就是英语中的主语、谓语和宾语,好吧这些背单词的时候记住就行。标点符号也是词法元素。
  2. 语法识别,上面的句子对应的语法是:谓语 + 主语 + 宾语 + 问号 => 疑问句

    词法识别和英语学习中背单词一样,计算机通过正则在字符串中匹配词,构成语言的基本结构,这些结构按照一定组合规则构成语法。Yacc所做的,是把扫描一串字符串,识别其中的词,把词和所描述的语法一一对照,然后能够得到一些结构化的数据,比如上面英语,计算机就能够知道,这是一个疑问句,疑问句的三个成分是what、you、name,至于这个句子什么意思,你应该如何处理,这是编译过程的第二步了。

velocity syntax

上面简单描述了一下原理,现在开始写语法规则分析器吧。写编译器就是把一套语法规则描述清楚,就像翻译一篇说明书。当然,我们首先需要能明白说明书的意义,本文以velocity模板语言为例,velocity是Java实现的一套模板,是阿里集体后端webx框架的模板语言,语法规则文档,可以大致看下语法,或者点击此处在线尝试vm解释过程。

vm(velocity简称)语法规则很简单,大概开5分钟就能学会,vm虽然简单,但是也是一套比较基本的计算机语言的实现了,对比下,英语我们学习了10年,还没能学好,vm只需要5分钟,自然语言的复杂度,比起计算机语言实在不是一个数量级。

#set( $foo = "Velocity" )
Hello $foo World!

vm语法分为两部分,第一部分是vm语法内容,另一部分是字符串,模板语言都是如此,字符串部分无需考虑,原样输出即可,vm语法主要是前者结构分析。上面的vm输出Hello Velocity World!。语法部分,主要分为两部分References和Directives。

References 和 Literal

References是vm中变量,解析时References输出为变量对应的值,模板语言最基本的功能也就是变量替换,vm同样如此,只是vm还有一些其他复杂的功能。Literal和js里面的字面量一直,是vm里面的基本数据结构。vm是一个模板语言,变量的值可以来自外部,而且是主要数据来源,References和Literal这两者构成了vm语法的基本数据。

References基本形式是$foo,或者加一些修饰符$!{foo}。复杂形式是,变量+属性,支持的属性方式有三种:

  • Properties 最普通的属性$foo.bar
  • Methods 方法$foo.bar(),因为方法是有参数的,参数由References和Literal构成
  • Index 索引$foo['bar'],index可以是字符串,也可以是变量References

上面三种方式和js的对象属性查找方式一样,因为存在Methods和Index,方法和Index本身又可以包含References,引用的组成部分可以是引用。这样式描述形成了递归,语法一般都是通过递归的形式来相互包含。引用(References)里包含自身,这如果使用普通的字符串匹配,逻辑上会有些晕。

Literal是基本的数据结构,分为字符串、map(js中的对象)、数字、数组。map的值由Literal 或者References构成,数组元素同样,字符串和数组相对简单,可以直接从源文件中匹配得到。到此,应该大致明白编译的复杂了吧,就这些基本的数据结构相互包含,要理清其中结构,还是很麻烦的吧,虽然我们可以一眼就知道这些结构,如何让计算机明白,就不那么容易了。不过,通过yacc,我们只需要描述清楚这些结构就行,怎么理清其中关系,Jison会自动处理的。

Directives

前面引用和字面量部分,是vm中关系最复杂的结构了,Directives是一些指令,包括逻辑结构和循环,模块之间引用加载等运算。这些结构比较好搞定,一般都是各自不相干,不像上面,相互引用,纠缠不清。vm解析,最复杂的还是在于引用的确定。

Directives分为单行指令和多行指令,单行指令作用范围是一句,比如#set#parse,多行指令主要是#macro,#foreach,if|else|elseif,这些都是通过#end来结束,这样的区分可以在语法分析阶段完成,也可以在后期处理。

语法分析

本文有些长,已经开始靠近目标了。上面描述语法的过程,是非常重要的,使用yacc描述语法规则,就是对输入源分类的过程。经过上面的分析,yacc的已经差不多构思好了,接下来把规则用yacc语法写下来就好。

在写yacc描述之前,需要做一件是,lex词法分析。词法分析就是要找到上面说的References、Literal、Directives的基本元素。新建一个文件velocity.l,开始写lex描述。

References

从References开始,vm里面引用的最主要的特征是符号$,首先假设有一个vm字符串:

hello $foo world

其中,$foo是References,很明显References是美元符号开头,$后面跟字母,这里需要引入状态码的概念,因为$后面的字母的意义和$前面的字母意义是不一样的,那么当扫描到$以后,可说我们处于不同的状态,区分好状态,就可以专心处理之和vm语法,否则同样的一个字符,意义就不一样。这个状态,我们用mu表示,状态吗可以随意命名,使用mu,是有渊源的,handlerbars的lex文件因为继承了Mustache语法,mu表示Mustache语法开始,我参考了handlerbars,所以用mu

velocity.l写下:

%x mu

%%
[^#]*?/"$"         { this.begin("mu"); if(yytext) return 'CONTENT'; }
<mu>"$!"           { return 'DOLLAR'; }
<mu>"$"            { return 'DOLLAR'; }
<INITIAL><<EOF>>   { return 'EOF'; }

%x声明有的状态码,状态码和字符串或者正则表达式组合成一个特征,比如&lt;mu&gt;"$",双引号表示字符串,这个特征描述表示,mu状态下,遇到$,返回DOLLAR。我们用DOLLAR描述$,至于为什么我们要给$一个名字,再次回到英语中,我们会把单词分为名词、动词,各种分类,语法规则不会直接处理某个特定的词如何组合,而是规定某一类词的组合规则,比如,最普通的句子,主语+谓语+宾语,主语一般是名词,谓语是动词,宾语也是名词,这样描述要简单得多,lex词法分析是给字符做最小粒度的分类,最终,一个vm输入源码,可以归纳到一个分类里,符合英语语法的字符串,我们统称为英语。

特征都使用全大写字母,这是一种约定,因为在yacc描述中,语法规则名都用小写。%%后面第一行,[^#]*?/"$",这是一个正则表达式,正则分为两个部分,第一部分 [^#]*?匹配所有不是符号#的字符,后面一部分"$",中间反斜杠分割,是一个向后断言,匹配美元符号前面所有不是符号#的字符,也就是遇到没有符号的时候,后面通过 this.begin开始状态mu。这里使用到yytext,就是前面正则所匹配到的内容,有个细节,这个匹配去除了#,因为#是另一种状态Directives的开始,这里暂时只讨论引用识别。最后一行,表示结束返回,这个无需理解。

引用的最基本形式,$ + 字母,美元符号识别了,接下来识别后面的字母,使用正则表达式

 <mu>[a-zA-Z][a-zA-Z0-9_]*   { return 'ID'; }

如此,我们可以用这两条规则,开始写第一条yacc语法规则了:

reference
   : DOLLAR ID
       { $$ = {type: "references", id: $2} }
   ;

上面描述的是reference,由lex中返回的DOLLAR和ID组合成为一个reference,大括号里面写的是js代码,用于构造结构化数据,需要什么样的数据可以自己随便搞,$$表示返回结果, $1是DOLLAR词对应的字符串,也就是$$2表示第二个词,也就是ID。复杂的reference可以继续写:

reference
  : DOLLAR ID
  | DOLLAR ID attributes 
  ;

attributes
  : attribute 
  | attributes attribute 
  ;

attribute
  : method 
  | index 
  | property 
  ;

property
  : DOT ID 
  ;

index
  : BRACKET literal CLOSE_BRACKET 
  | BRACKET reference CLOSE_BRACKET 
  ;

reference在原来的基础下,增加了attributes,attributes是由一个或者多个属性组成,在yacc中,使用attributes attribute来描述多个属性的情况,规则直接包含自身的情况还是非常常见的。attribute由 method,index,property 组成,继续拆分,index是两个中括号加一个literal或者 reference 组成,我们可以继续对literal进行分类,同样的描述。我们回到了上面对vm 语法描述的那个分类过程只不过,现在我们使用yacc的语法描述,前面使用的是自然语言。

解析过程

上面讲了那么多,现在来总结一下Jison解析一个字符串的过程。用一张图表示吧:

lext

词汇分析过程就是上面所描述的了,一个lex文件,构成一个词汇表,通过从左到右的扫描输入源,依次匹配词汇表里面定义的模式,然后构成一个个词汇。得到词汇之后,那什么是语法呢,还记得英语语法吗?在计算机里面,语法就是上面所描述的,词汇的组合,规定了词汇的组合形式,比如DOLLAR ID组成一个reference,写yacc语法规则就是不断的对语法进行分类,直到所有的分类最底层都是lex中的词汇,然后语法规则也就ok了。程序会自动根据yacc文件所有定义的规则,分析得到输入源对应的数据结构。

velocity最终的语法描述在这里

状态码

上面简要描述了yacc和lex工作原理过程,实际中,还是会遇到一些有意思的问题。在写vm解析器的时候,最麻烦的事情是,如何保证括号和中括号的匹配,首先看一段vm字符串:

$foo.bar($foo.name("foo")[1])
$foo.bar([)]

经过分析,我发现括号匹配的一个特点是,括号的闭合状态下,它的前一个状态肯定是括号开始,中括号同样如此。因此,我在velocity.l中再引入两种状态,i, c,分别表示括号开始和中括号开始,在匹配到括号或者中括号结束的时候,判断前面的一个状态是否是符号的开始,这样,就能保证括号和中括号的配对。

在lex词汇分析中,状态码是一个堆栈,这个堆栈通过this.begin开始一个状态,this.popStat退出一个状态,词汇可以是多种状态和正则表达式进行组合,状态的开始和结束,需要自己控制,否则可能会有问题。

解析最终得到一个对象,这个对象的构造是根据velocity.yy而生成的。如何选择合适的数据结构,这个是很很重要的,后面的语法树解释过程,完全取决于解析器所返回的语法树。在velocity的语法树,最终得到的是一个一维数组,数组的元素分为字符串和对象两种,如果是对象,那么是vm语法需要进行分析解释的。

语法树解释

得到输入源语法结构之后的工作,相对而言就容易了,这其中会涉及到两个点,我个人觉得比较有意思的。第一个是局部变量,在vm语法中,有一个指令#macro,这个是vm的函数定义,由函数,自然有形参和实参,在函数执行过程中,形参是局部变量,只在函数解析过程中有效,#foreach也会形成一个局部变量,在foreach中有一个内部变量$foreach.index, $foreach.count, $foreach.hasNext这样的局部变量。

局部变量的实现,可以参考lex语法分析过程,在语法树解释过程中,增加一个状态码,当进入一个foreach或者macro的时候,生成一个全局唯一的条件id,并且在状态中压入当前的条件id,当foreach和macro运行结束后,推出一个状态。foreach和macro控制状态,同时构造一个数据空间,贮存临时变量,在vm解析过程中,所有的变量查找和设置,都是通过同样的一个函数。当一个变量查询时,检测到存在状态时,首先依次根据状态码,找到对应状态下的局部变量,如果需要查询的变量在局部环境中找到,那么返回局部对象对应的值,如果是这是值,同样如此。这样的实现和js所中的函数执行上下文有点类似,可以继续想象一下如何实现避包,实现避包其实只需要在一个函数中返回一个函数,这样的语法在vm中没有,不过如果真的可以返回一个函数,那么只需要在这个函数和当前函数执行所对应的状态放在一起,并且不释放状态对象的局部变量,那么避包也就有了。

结束

本文到此要结束了,不知道是否有说明白,具体实现细节可以参考velocity.js源码

vim如何成为神器

这个问题,让我思考了很久,vim的特色之处到底是什么,最开始使用vim是因为学长的建议,同时vim有着编辑器之神的传说。玉伯指出,vim比起IDE,有些落伍了。这让我很震撼,大家从来都是在讨论vim和emacs之争,很自然的,vim党或者emacs用户,都没有把IDE放在讨论的范畴之类。我等也不过,远远的看着这些所谓的争论,毕竟,我只是一个vim用户而已,那些论战是牛人们的事情,vim有多好,他们自然可以证明,或者说,这无需证明,vim很优秀,这是公论了。

为此,很荣幸有机会看到玉伯分享IDE的相关操作,确实,IDE有着很多优秀的特性,是vim 所无法做到的。关于此问题的讨论,也没有一个vim用户站出来,像玉伯一样,用一个比较有力的观点说明vim的特色之处。神器毕竟只是一个传说,那么,vim为何成为神器呢?带着这个问题,我翻了很多资料,所获甚少,关于神器的传说,似乎没有出处。

于是,开始重新仔细阅读vim自带的文档,从vim官网上查看一些信息。可以确定的是vim从来没有自称神器,在一篇vim 20周年的文章中可以看到,vim官网所提出的,为何人们还在使用vim的几个理由:多文件操作(Buffer和分屏)、有模式的、多个寄存器、宏、高度可配置扩展、跨平台。这些,所有的现代编辑器,IDE都(除了多模式)能做到,而且做的都还不错。

似乎,vim不过一个普通的编辑器而已,但是,作为一个忠实的vim用户,我感觉,这个普通的编辑器,有着自己无可比拟的特殊之处,我觉得,我应该找到这些特殊的,让无数程序员着迷的特性是什么。我个人使用vim不过3年,3年时间,对于一个vim用户来说,一点都不长,甚至是刚入门一会儿,我知道我个人的理解可能不那么准确,但是我还是总结了几点,我个人觉得是vim特色之处,是IDE所不可比拟,是那些IDE的所谓的vim模式所做不到的。

我认为vim和IDE最大区别在于,vim是有模式的,vim是unix一部分,秉承这unix哲学——一切都是文本,vim中的命令是原子性的,可以随意组合。下面一一解释,vim的特色之处很多,不过也有一些是IDE所擅长的,比如自动提醒之类的,IDE可以内置一个语言引擎,这样提示确实要强大的多。

模式

vim的模式,是的vim可以有多种编辑状态,最常用的是普通模式,输入模式,视图模式,命令模式,普通状态是vim的默认模式,所有键盘的按键都是你可以使用的快捷键,这时候,你在用的似乎不是在编辑文本了,更确切的说是,处理文本,键盘不再是输入字符,而是 vim的各种命令。这样,使得vim非常快速做各种改动。写代码过程中,其实我们花更多的时间在调试,修改代码,而不是输入代码,写代码。所以,我们需要vim的正常模式,进入vim 首先进入的是正常模式。

有人表示,vim的模式看起来非常诡异,让人无法理解,模式切换复杂。其实,古老的vi使用esc进入正常模式,现代的vim已经提供很多其他更方便的方式模式切换 <C-o>, <C-[>, <C-c>(C表示Ctrl键),vim基本只需要使用大键盘就ok了,因为有模式,可以随意绑定各种快捷键。各种现代的编辑器或者IDE,都有自己的选择模式和命令模式,不过在vim里面,正常模式是特有的,在正常模式下,vim的命令大致分为两种,操作和文本对象,文本对象是的vim里面所有文本区块是各种符合程序员思维的区块,vim提供了丰富的文本对象,w一个单词,p一个段落,s一句话,[]表示一个中括号块,类似的还有 ( ) { } < > " ',t一个html tag组合,这样的区块在程序代码中经常出现,更多请看:help text-object。确定了区块,然后可以执行各种操作了,比如删除,修改边界符合,在一个区块里进行替换,或者移动某个区块单位。

正常模式下,使用最多的是光标的移动了。在代码中找到目标代码,有文本区块的概念,就非常容易,比如,我们想在一个{}两端跳转,输入{就可以了,va{就可以选中一个{}包围的区块。也许有人会说,使用鼠标移动要比键盘移动快, vim的h j k l只是一种移动方式,单步移动是最基础的移动方式,vim里面更快速的是使用区块移动,一个单词,一个大括号包围的区块,等等。vim不仅仅可以移动光标,还能移动视窗位置zz zt zb,把光标所在位置设置为视窗中心,或者顶部、底部,这样的功能还是非常实用的。鼠标移动的最大问题是,鼠标是不准确的,如果我们确切知道自己要如何移动,键盘的方式更快速。对于鼠标比较适用模糊定位,vim 也能做到,在光标附近,我们明确想要去某个位置,而不是某个区块的情况下,vim使用 easymotion插件可以快速而准确定位。具体实现是,把光标位置后面的每个单词的第一个字母标记为a-z 26个字母,然后输入你想要去的单词前面的那个字母,也可以定位到单词所在位置了。你需要做的是两个步骤,高亮单词首字母,选择你要去的位置字母。这和鼠标的速度,至少不相上下,而且更加准确。

说的有点抽象,举个例子吧:

hello world. We use vim for the ohly editor.

如果光标恰好在world的d上面,现在要去修改oely,把e改为n,vim可以使用2fhrn,这样完成修改,这样或许显得有些繁琐,还得首先调用f进行查找,鼠标可以直接定位修改,那么vim的快速模式,执行的命令是,,w此时,启动easymotion,文本变成了:

hello aorld. be cse dim eor fhe ghly hditor.

对比前面可以看到,所有单词的第一个字母都有了编号(hellow world变成了hellow aorld,a就是编号,a会高亮,其他都为灰色),可以很快定位目标单词的位置,然后输入g,跳转到ohly(ohly变成了ghly,g是编号)的o上,输入l右移一个单位,rn,修改h为n,ok完成。这里提到的只是一个基本功能,easymotion可以做更多快速定位操作的。上面这种模式,在vim的选择模式下同样有效:

hello world. We use vim for the only editor.
hello world. We use vim for the only editor.
hello world. We use vim for the only editor.

上面的代码,如果选中了第一行的hello world,下面我要继续选择第三行的editor,这个时候,vim的easymotion,vim的文本区块选择还是依然有效的,如果是用鼠标,鼠标的选择和键盘是互斥的,你只能老老实实拖拽到第三行吧。这里,其实也是后面要说的,vim一致性体验的一个方面,在各种模式下,所有的操作都是通用的。

一致性

一切都是文本,这样其实意味着,在vim里面高度的编码一致性体验。一致性体验,这个只有在vim和emacs里面可以做到的。这意味着,在vim中,你的所有操作,都是vim风格的,处理正常的文本处理,我们有时候需要调用终端,或者文件操作,文件查找等等。在vim里面操作一个文件也是一个文本,这样,修改文件夹和修改文件名一样了。IDE里面总是有着各种窗口,这些窗口彼此是独立的。vim只有一个窗口,在这个窗口里面所有操作都是一致的。

一致性在上面也提到过,easymotion的移动,在选择模式和正常模式下一致的。在比如,vim 的帮助文档,统一的入口是:help命令,文档是vim的一部分,文档的浏览也是通过 vim的键盘命令导航的。而大多数IDE的帮助文档,都是一个独立的系统。比如在emacs里面,对一个文件夹的操作,和操作一个文本是一致的,这在vim同样可以做到。emacs自带eshell ,终端可以跑在emacs里面,这时候,终端就是emacs的一个buffer,同样在vim也可以做到类似的功能。IDE 可以自带一个shell,不过,ide的shell更像是一个shell的窗口放在ide里面,而vim里面, shell就是vim处理的一个文本,它在vim里面,和普通的文本处理没有本质的差异。或许vim 只能处理文本,所以所有操作都以文本的形式存在,所有操作可以使用同样的键盘命令移动方式使用,nerdtree的文件节点同样是一个buffer,一堆文本。而IDE 里面,文件结构树是IDE一个与编辑窗口不一样的一个窗口对象,IDE的其他窗口里面,编辑模式下的快捷键移动等,都不再有效。

命令组合

很多人说,vim里面能够做到的快捷键,IDE都能做到。我觉得,vim里面的操作,很多时候不应该称之为快捷键,应该说是命令,编辑器命令。使用vim操作文本,其实就是在调用编辑器命令。这和IDE所谓的快捷键是完全不同的概念,IDE把命令封装成一系列快捷键,但是这些命令本身对使用者是不可见的,而vim里面,所有按键都是一个最小单位命令:一个操作,一个文本对象区块,或者一种模式,使用vim就是在不停的调用各种命令。比如,daw,在 vim里面表示delete a word,d a w是三个彼此独立的命令,d表示的是执行操作删除,aw 是作用范围,可以看做是函数传递的参数。你可以用d后面接受各种文本区块的组合, aw:a wordap:a paragraph 等等,同样,aw前面也可以接受其他任意各种命令,比如vvaw选中,yyaw复制。这是其他任何IDE所做不到的,IDE 里面的快捷键就是一个功能,快捷键是不能拆分成单独命令。命令可以拆分,你可以在各种模式下使用这些最小命令单位。在 vim里面,选择一部分文件修改,和修改整个文件是没有任何区别的。

命令最小化的优势在于,你能做任意你想做的事情,这些命令的各种组合,记住一个命令,它能够在多种情况下使用,看似命令繁琐,其实命令所作用的方式在vim里面是一致的。y表示复制,对文件的复制用的也是y命令。

举个例子,vim插件surround,操作文本:"hello", ds" => hello,cs"' => 'hello' 。这里使用两个命令ds"cs"',乍一看似乎很诡异的语法,其实了解了vim的命令原子特征,就可以很轻松理解这两个命令了,ds"表示delete surround ",双引号在vim表示双引号围起来的区块,d是vim中删除的基本命令,dw删除一个单词,dap删除一个段落。cs"'这个命令表示,change surround " ',c是change的基本命令,这个命令也就很好理解了吧。在 IDE里面,相同的操作大概是一个快捷键,记住那么一个快捷键,你能删除一个",可能你还得记住另外一个快捷键删除一对括号,一对大括号,一对各种其他符号,这些在vim里面,不用看文档都知道怎么做了吧ds( ds{ ...。更重要的是,这些原子命令,在任何地方都是有效的。每个vim命令都是一个小功能,组成一个强大的文本编辑器,就像 unix由各种小部件构成一样,这是任何IDE所无法做到的。

其他

这里讲一个小故事,最近前端模板比较流行使用编译的方式来写语法解析,一般的一套模板语法就是一个和上下文无关的语法规则,比如handlebarsjs,coffeejs。承玉为kissy增加新的xtemplate,同样使用的是Bison的js实现来生成语法规则,Bison语法由yacc和lex两者组成,前者是语法规则,后者是语法中词法规则,语法由词法构成。至于什么是yacc和lex,这里不讨论,咱说的是编辑器。承玉实现了Bison的js版本,kison,同样的js实现还有Jison, handlebarsjs和coffeejs都是用Jison生成解析器的。kison和Jison都接受json文件作为词法和语法规则描述文件,不同的是Jison还支持标准的yacc和lex文件格式,而kissy的xtemplate 语法描述文件是json的。这是问题的关键,实际上,yacc文件描述要比json容易理解得多。handlebarsjs的语法描述是yacc形式的,coffeejs的语法描述原来也是yacc格式的,不过,后来,改成了coffeejs写的了,coffeejs为了保证coffee源码整体都是coffeejs写的,所以使用了coffeejs形式。但 xtemplate为何会使用json,实际上,yacc转换为json应该不复杂的。而yacc文件描述语言,几乎是语法文档的标准,比如ecma 5.1文档对JSON的描述:

JSONText :
  JSONValue

JSONValue :
  JSONNullLiteral
  JSONBooleanLiteral
  JSONObject
  JSONArray
  JSONString
  JSONNumber

JSONObject :
  { }
  { JSONMemberList }

JSONMember :
  JSONString : JSONValue

JSONMemberList :
  JSONMember
  JSONMemberList , JSONMember

JSONArray :
  [ ]
  [ JSONElementList ]

JSONElementList :
  JSONValue
  JSONElementList , JSONValue

这样的描述,不用解释,就都大致可以看明白吧,如果写出json,一方面可读性不好,另一方面,语法有错误也不好定位,没有语法高亮。饶了这么久,终于到正题上了,为啥kissy没有使用yacc而是json,我个人猜测,原因大概是,承玉是开发工具是IntelliJ IDEA,.yy文件根本无法识别,市场上也没有这么一个插件支持yacc语法,这么古老的东西,大概不会有现代编辑器支持了吧。subline text2同样,.yy文件也只能当成是plain text处理。而我也恰好写了一个 velocity的模板解析器,用vim打开.yy的文件,毫无压力,语法高亮,错误提示全部都有。我相信,没有程序员愿意对着一个黑白的编辑器写代码吧。

故事到此为止,vim是古老一些了,但vim从来不是为某一种语言,某一类开发者准备的,它是一个文本编辑器,能做文本编辑器应该做的一切。也许,大多数前端都在使用js html和 css,一个支持js html css的编辑器就足够了,但是,你永远无法预料自己将要使用什么语言,将会面临什么文件。这一切,在vim看来,都是文本而已,任何文本的编辑处理,纳入到 vim中,它和编辑其他文件没有差异。使用vim,你拥有的不是开发某一种语言的能力,而是一套通用的高效的文本编辑方法,工具。也正是因此,vim在鼠标已经流行了很多年,现代编辑器,IDE发展非常强劲的时代,依然没有被淘汰。 编程环境总在变化,语言自身也在发展,发展如此之快,IntelliJ IDEA的模式,在现在有效,不知道过多少年,它也许就不再有效了。

总结

关于vim的神话,那大概只是一个传说。vim只是一个有特色的编辑器而已,也是一个非常值得拥有的工具,它定义了一套独特的文本编辑方案,熟悉它,可以让你在unix上获得自由,可以让你面对文本操作任务,随心所欲,指随意动。同样的,vim只能处理文本,它永远无法做到IDE可以实现的一些功能,但无论如何,都不能抹杀vim的优秀之处。

一次有意思的内部讨论-sku组合查询算法探索

在前端领域,很少会遇到算法问题,这不能说不是一种遗憾。不过,随着前端处理的任务越来越复杂和重要,偶尔,也能遇到一些算法上的问题。本文,所要讨论的,就是这样一样问题。

什么是SKU

问题来自垂直导购线周会的一次讨论,sku组合查询,这个题目比较俗,是我自己取得。首先,看下什么是sku,来自维基百科的解释:

最小存货单位(Stock Keeping Unit)在连锁零售门店中有时称单品为一个SKU,定义为保存库存控制的最小可用单位,例如纺织品中一个SKU通常表示规格、颜色、款式。

让我们假设在淘宝上,有这么一个手机,如下表格所示:

颜色容量保修期限屏幕大小电池容量
红色4G1 month3.71500mAh
白色8G3 month41900mAh
黑色16G6 month4.32100mAh
黄色64G1 year 2500mAh
蓝色128G   
sku: 白色 + 16G + 3 month + 3.7 + 2100mAh就这么一款可以提供5种颜色,5种容量,4种保修期限, 3种屏幕尺寸,4种电池容量的手机,我们假设它存在,叫xphone。表格中,加粗的5种属性,组合在一起,构成一个sku。现在,应该清楚什么是sku了吧。可以把xphone的规格参数看成一个JS的构造器,每一个sku,对xphone函数进行实例化,返回的一个对象就是一个sku。不过,这和一部手机的概念有一些区别,一个sku对应多个手机,sku是描述手机的最小单位,比如说学校,在学校里面最小教学单位是班级,那么一个班级可以看做一个sku。

问题描述

下面,为了描述问题,我首先假设一个产品属性组合2x2,用[[a, A], [b, B]]表示,那么,sku组合为[ab, Ab, Ab, AB],也是2x2,4个sku。现在我们知道sku对应的数目和价格,依然用js对象来描述为:

{
    ab: {amount: 10, price: 20}
    aB: {amount: 10, price: 30}
    AB: {amount: 10, price: 40}
}

这个的数据说明了,Ab是没有货存的,ab, aB, AB分别有10个货源在。那么,当用户对商品进行选择的时候,如果首先选择A,那么,b应该显示为不可选择状态,因为Ab是没有货的。同样,如果选择了b,那么A应为灰掉,因为Ab还是没有值的。可能的几种状态如下:

初始状态
属性1:
属性2:
1. 选中b,A禁止
属性1:
属性2:
2. 选中A,b禁止
属性1:
属性2:
3. 选中AB,价格为40
属性1:
属性2:

问题:用户选择某个属性后,如何判断哪些属性是可以被选择的。当sku属性只是2x2的时候,还是很容易计算的。但是,如果情况变得复杂,比如4x4x4x5这样的情况,要判断用户的那些行为是可行的,还是会复杂很多的。下面看算法实现吧,还是用2x2这种最简单的形式作为参考。为了方便描述,下面使用result = {ab: ...}表示sku对应的价格和数目的数据对象,使用item表示一个sku属性下的一个元素,items = [[a, A], [b, B]]表示所有sku属性元素。

算法演示

首先来一个演示吧,仅支持高级浏览器。对于第一算法,使用正则匹配,不是很完善,有些不准,仅仅是演示,正则表达式写的不好,不用在意。

下面灰色按钮表示不可选,白色表示可选,红色为选中状态。演示框最下面是可用的sku组合。

第一种算法[正则]:
共进行323次运算,耗时2ms
属性1:
属性2:
属性3:
属性4:
属性5:
属性6:
属性7:
第一种算法优化方式[除法]:
共进行387次运算,耗时1ms. result乘积最大为67672188866017
属性1:
属性2:
属性3:
属性4:
属性5:
属性6:
属性7:
可选择的路线:
3:23:83:101:137:179:227
11:53:67:131:157:191:251
7:29:73:127:167:211:257
17:47:71:103:151:199:241
3:41:89:107:151:223:257
19:53:83:109:163:199:229
13:43:79:101:173:211:239
19:47:67:127:157:191:229
3:37:61:107:167:223:251
2:31:89:97:151:199:257
13:29:59:113:149:193:227
11:41:73:103:173:181:263
19:43:89:103:151:197:263
3:53:83:107:149:199:241
11:29:71:131:157:211:233
13:31:61:113:173:181:229
2:37:83:127:137:211:251
17:53:89:97:151:191:229
3:31:73:103:167:193:239

第一种算法

初始条件
已知所有sku属性的数组items和sku所对应的价格信息result
用户选择了item B,使用数组selected=['B']表示,selected可以为空数组
算法过程
1. 循环所有sku属性forEach(result, (curitems, attr)-&gt;),使curitems等于属性对应的所有元素,attr等于属性id。
2. 克隆数据attrSelected = selected
3. 判断属性attr中是否有元素在数组attrSelected中,如果存在,从attrSelected去掉存在的元素
4. 循环属性下的元素forEach(curitems, (item)-&gt;,使得item等于单个属性的值
5. 把 attrSelecteditem组合成sku
6. 循环result,判断第五组成的sku在result中是否存在,如果存在,退出循环4,返回true,进入步骤8
7. 当前item设置为灰色,标志不可选择
8. 当前item为可选属性元素
9. 循环4和循环1完成,所有item状态标注完成,算法结束

这个方式是最普通的算法实现了,非常直接,一个一个判断所有的item是否可以被选中,判断依据是itemselected的元素组合的sku是否在result数组中存在。在我们上面的例子中,在初始化的情况下,用户没有选中任何元素,那么循环过程,只需要判断a, b, A, Bselected是否存在。如果,用户选中了b,那么循环过程中,依次判断的sku组合是ab, Ab, B,存在的sku组合是ab, aB, AB,所以因为Ab组合没有能知道,所以,A需要标注为不可点。组合sku判断的时候,需要注意的是,因为B和选中的b在同一个属性中,所以组合的时候,需要去掉b,然后组合成B,这是第3步所主要完成的过程。

这样的算法,很简单,但很繁琐,循环嵌套循环,可以简单分析一下算法复杂度。如果sku属性组合元素的总和数用m表示,结果数据长度为n,那么每次选择后,需要的算法大致步骤是m n。这似乎不是很复杂,m n而已,不过,每次判断一个sku组合是否和result中的组合匹配,却不是一个简单的过程,实际上,这可以看做是一个字符串匹配的一个算法了,最简单的还是使用正则匹配,m * n次正则匹配,这样就不怎么快了吧。正则表达式很不稳定,万一sku组合中有一些特殊字符,就可能导致一个正则匹配没能匹配到我们想要的表达式。

第一种算法的优化

经过讨论,第一种算法,有了优化的算法思路。 就第一种算法而言,正则匹配不够优雅,而且比较慢,而我们想要做的事情是比较一个组合是否包含于另外一个组合,用数学语言来描述,就是一个集合是否是另一个集合的子集,怎么来做这样的快速判断呢。

现在问题可以简化为:假设一个集合A{a, b, c}和另外一个集合B{a, e},如何快速判断B是否是A的子集。这个问题比较简单的方法是用B中所有元素依次和A中的元素进行比较,还是简单而粗暴的方式,比正则稍微快一些。对于集合中的元素,它们都以唯一的,通过这样的特性,我们可以把所有字母转换为一个质数,那么 集合A可以表示为集合元素(质数)的积,B同样, B是否是A的子集,这个只需要将B除以A,看看是否可以整除 ,如果可以那么说明,B是A的子集。

现在处理字符串就转换为处理乘法算法了,有了以上的分析,我们可以整理下算法过程:

  1. 数据预处理,生成一组随机数,把所有item一一对应一个质数,把item组合转换为一几个 质数的积
  2. 根据用户已经选择的item进行扫描所有的item,如果item已经被选中,则退出,如果没有, 则和所有已经选择的item进行相乘(特别注意,以选中的item需要去掉和当前匹配的item 在同一个类目中的item,因为一个组合不可能出现两个类目相同的item) ,这个乘机就是 上文中的集合B
  3. 把集合B依次和sku组合构成的积(相当于上文中的集合A)进行相除,比较,如果整除,则 退出,当前匹配的sku可以被选中,如果一直到最好还没有匹配上,则不能被整除。

这样优化了一下看起来比较简单的思路,但是实现起来却一点都不容易,代码在这里。算法也算简化了不少,不过这个预处理过程还是比较麻烦的,而且实际上,和第一种方案的解决的算法复杂度差不多,只是比较的时候使用的是乘除法,而第一种是正则匹配罢了。

第二种算法

后来又过了一周,这个问题被当成一个方案来继续讨论了。大家此时差不多都无话可说了,算法都有实现了,似乎没有什么其他可说的了。就在这个问题就如此结束的时候,正豪站出来了,说不管是第一种还是第一种方案的优化方案,每次用户进行选择,都需要重复计算一遍,这样实在太麻烦了。每次都对所有spu进行扫描,这样不是很好,能不能有其他的方式呢,能否更加直接判断出一个sku是否可以被选择呢。前面的算法,一个sku是否可以被选择,需要依次循环sku 组合的所有元素才可以判断的,这样的过程一定需要吗?

第三种算法就这样诞生了,考虑到JavaScript中的对象属性访问是最快的了,那么对于如果能够直接从一个对象中读取到以选择的sku和需要匹配的sku组合对应的数目,那这样的算法简直就是不用时间啊。下面来详细描述。

下面把问题初始条件假设如下:

初始状态,选中A1
属性1:
属性2:
属性3:

假如已经选中item为A1,那么现在要计算B1是否可以被选择,那么如果我们能够直接获取到A1和B1组合的所有商品数目,那么就能知道B1是否可以被选择了。A1和B1的组合是这样计算的,在上面描述的问题空间中,A1和B1的组合,可能有以下几种: A1+B1+C1, A1+B1+C2,A1+B1+C3。这些组合就可以直接从已知的sku组合中获取信息啦,同样是对象属性查找,快得不得了。示例如下:

A1选中状态下,判断B1是否可用,只需要查找A1 B1 = + +
A1+B1+C1这样的组合,结果可以可以直接从result中获得数据结果。

实际上, 对于任何一个sku和其他sku的组合都是可以通过同样的方式递归查找来实现获取其组合后的商品数目。这样的算法最大的优势是,计算过程是可以缓存的,比如计算A1是否可以被选中,那么肯定需要计算除A1+B1组合的数目,A1的数目是由A1+B1,A1+B2,A1+B3三个子集构成,这三个子集又可以拆分为更细的组合,然后这些所有的组合对应的商品数目都可以获取到了,下次需要判断A1+B2组合,则无需重复计算了。此外,我们可以清晰的获取组合相关的信息,比如某个sku下面可以有的商品数目。

算法实现这里jsfiddle

复杂度分析

第二种算法思路非常有趣,使用动态规划法,将原问题分解为相似的子问题,在求解的过程中通过子问题的解求出原问题的解。而且,最终判断一个item是否可以被选择,直接从对象中查找,属于字典查找算法了,应该是很快。但是,乍一看,还是有些问题,递归查找,数据贮存在变量中,这些都是通过空间来换取时间的做法,递归会堆栈溢出吗?查找次数到底多少?

第一个种算法的复杂度还是很容易计算的,首先假设一个n n的矩阵构成sku属性,比如10x10表示,有10个属性,每个属性有10个元素。假设可选择的result长度是m,那么,第一种算法的复杂度大概是 n n * m,这样的算法还是很快的。只是,如果每一个步骤,都使用正则表达式匹配,根据上面的演示,确实会有一些些慢,不过正则表达式的是模糊匹配,可能不是那么稳定。不过除法方式判断需要生成足够的质数,当几个数的乘积太大的时候,可能导致计算机无法运算,所有,使用第1种算法的优化算法,也是有一定限制的。js里面,能够处理的最大数字大概是19位,这个范围内可以操作的范围还是比较大的,这个就不做推算了。此外,通用可以引入负数,这样就可以把质数的范围增大一倍,计算量也小一些,可以处理更大的输入规模了。

第二种算法复杂度,同样对于n * n的数据输入,从第一排算起,第一排第一个A1,组合为A1 + B1, A1 + B2 ...函数递归进入第二层,第二层从第一个B1开始,组合为A1 + B1+ C1, A1 + B1 + C2 ...进入第三层,以此类推,函数每增加一层,需要的计算量是上一层的n倍,总数是 n + n2 + n3 + ... + nn,这个数目是非常庞大了,算法复杂度用nn来描述了,如果是10x10的sku属性组合,初始化需要100亿次计算,有些吓人了,这还需要一个同样庞大的内存数组。

第二种算法的优化

经过上面的算法分析,似乎第二种算法是错误的,无法执行。不过,仔细想想,第二种方法第一初始化的时候算法复杂度非常高,几乎是浏览器无法承受的。但是,一旦数据初始化完成,后面的过程就非常简单了,同样对于n n规模的输入,每次用户选择,这个时候,需要进行的操作是把所有数据遍历一遍,然后直接查询是否存可以被选中。算法复杂度是n n。比起上面第一种算法的优化算法要快,现在主要的问题是,初始化如果使用自上而下,不断拆分问题,这样运算复杂度指数级增加,不过,算法本身是可行的,数据初始化过程,还是需要进一步优化。

第二种算法,把问题一层一层拆分,查找过程分解太过于琐碎,有很多的组合,是完全不可能存在的,算法非常浪费。如果,直接从获得的result数组中读取数据组合,只需要把result循环一遍,所有可能的组合就都可以计算出来了。举个例子,从最上面的2x2的result中,我们知道result对象

    ab: {amount: 10, price: 20}
    aB: {amount: 10, price: 30}
    AB: {amount: 10, price: 40}

计算过程,循环result

  1. 第一次分解ab,a = 10, ab = 10, b = 10
  2. 第二次分解aB, a = a + 10 = 20, aB = 10, B = 10
  3. 第三次分解AB, A = 10, AB = 10, B = B + 10 = 20

三次循环,得到一个新的数据结构var map = {a: 20, ab: 10, b: 10, aB: 10, AB: 10, A: 10, B: 10}通过这个对象,就可以判断任何情况了。比如,初始化的时候,需要查找a, b, c,d,直接查找map对象中是否存在a, b, c, d。如果选择了a,那么需要判断aB, ab,统一直接查找的方式。

经过这样的优化,初始化的时候计算量也不大,这样第二种算法的实现就可以很好的完成任务了。可能这个map对象,可能还是会有点大。

结论

总的来说,比较好的方式是第一种算法的优化(也就是除法判断)和第二种算法。各自有其特点,都有其特色之处,除法判断把 字符串匹配转换为数字运算 ,第二种算法使用 字典查找 。并且都能 快速准确 的计算出结果。

从算法速度来说,第一种算法复杂度是n n m,当然需要一个比较繁琐负责的质数对应转换过程,第二种算法复杂度是 n * n,其初始化过程比较复杂,最初的方式是nn,经过优化,可以提高到n!,n的阶乘。从理论上而言,nn或者n!都是不可用的算法了,就实际情况而言,sku组合大多在,6x6以下,第二种算法还是非常快的。

从算法本身而言,第二种算法想法非常奇妙,容易理解,实现代码优雅。只是初始化比较慢,在初始化可以接受的情况下,还是非常推荐的,比如淘宝线上的sku判断。此外,第二种算法获得的结果比起第一种更具有价值,第二种方式直接取得组合对应的数目,价格信息,而第一种只是判断是否可以组合,从实际应用角度而言,第二种方式还是剩下不少事的。

感觉只要善于去发现,还能能够找到一些有意思的解决问题思路的。

同伴提名测验平台

因为找工作的事情,在杭州呆了近四个月,然后回到长沙,闲置了一个月,工作还是没有确定,无奈,只能匆匆忙忙赶回学校写论文了,剩不到二十天时间。和盖老师商量,做实验或者写研究综述都来不及了,于是我说,如果能够做一个毕业设计啥的,也许更容易些。于是,盖老师给我推荐了这么个题目,同伴提名测验平台

对于任何计算机开发项目,最重要的就是一个词速度,开发速度和运行速度,具体来说表现在代码可读性、可维护性、重用以及程序运行的效率、性能等方面。不过,通常程序员写代码的速度会和程序运行速度相冲突的,微观来看,我们经常遇到的就是一个算法中时间与空间的权衡。那么究竟如何处理,这就得依据具体情况而定了。

大胆使用开源吧

一直对开源怀有一种敬畏的心态,感觉就像古人瞻仰圣人“高山仰止,景行行止。虽不能至,然心向往之。”有时甚至妄自菲薄,感叹,这个世界有如此完美的技术,强如jQuery、WordPress,那么我们这些程序员还能再做什么呢。当然,那时候我依然停留在Dreamweaver+ZendStudio+WAMP开发时代,实际上,我离开源还有一段好长的距离。同时,作为web开发者,总是对自己作为一个程序员是否合格感到心虚,从传统影响来看,桌面应用才算是真正的程序吧。去一个学校应聘时,那信息中心的老师说,哦,就是一个网页啊,哪里是什么信息系统啊。

现在想想,任何实现变量、流控制和基本运算的语言不都能完成其他任何一种图灵完全的语言的任务。他们的差别不过在于使用某种语言的人们是如何使用这门语言吧。和JavaScript相比,PHP都显得中规中矩,虽然大多数时间JS让人无比郁闷,但我还是更喜欢那种灵活的使用方式。还尝试过一段时间学习Pascal,我想我无论如何也无法理解,使用它的人们会称之女神。但依然有那么多人能够理解Pascal,任何一种技术,都只有被理解,被使用了,才算是成功的。所以,参与开源,就大胆地使用它们吧。开源之所以存在,不仅仅是为了人们去创造更多有意思的技术,更重要的是它们需要是有价值的,需要被使用。最近,看到John Resig在博客上谈到自己转到另一个开源公司了(而且是做教育方面的,呵呵,我也得去搞教育了,不过悲剧的是去教office),我很惊讶,美国那么多开源技术公司,它们是如何生存的,作为中国人,我有些无法理解,开源的公司也能存在。

讲了这么多废话,开始说说这个同伴提名测验平台吧。

前端MVC

第一次听说前端搞MVC还是今年4月初的时候,去阿里云闲谈了一会儿(算是一个不正式的面试吧),被问起你是如何看待前端MVC的。当时顿感鸭梨甚大,就随便答了几句。现在终于明白了,前端的MVC模式最重要的就是解决传统web开发中View层的可重复、模块化开发问题的吧。一年前在雅礼做教师信息平台时,感觉Codeigniter的MVC模式非常好用,就是在View层功能实在太弱了。每一个页面都得放一个视图的html文件,完全不可重复。写代码最难受的事情就是复制了。当时很郁闷的在控制器中定义一个数组来配置视图,勉强实现了头部、左侧导航和底部版权信息部分的重用。但是,头部稍微修改一个小链接,就得把View的php代码改得一塌糊涂。最近CI发布新版本,还整合了一个JavaScript类——神啊,JavaScript如此强大,如何能屈居于PHP之下。

所以,但今天,我再也不能忍受PHP和HTML混在一起了。OK,那就得用MVC模式啦。测验平台还是使用Codeigniter框架,只是它的MVC被我只用了M和C,View只有一个文件,简单定义下HTML头,加载一下css和js,其他的就这么些了,本来和特意准备了一个文件夹来放View的。这就是所谓的单页面web app啦。

Backbone And Seajs

下面最重要的两位上场了,首先谈谈Seajs。在使用YUI时,觉得,那个seed确实很爽,想用什么,直接load就行了,维护页面一堆script标签也是一件非常无聊的事情。在杭州时,就听说玉伯在考虑seajs了,当时想,什么都mixin,和我直接把其他对象的prototype引用过来有啥不一样吗。现在看看,就简单的这样引人类似于YUI中的seed文件了

<script src="assets/js/sea.js" type="text/javascript"></script>

比YUI更酷的是,这个seed完全不用配置,而且足够小巧。下面简单谈谈自己使用seajs的一些感受,不过这么十来天,seajs已经发布0.9正式版了,我使的是beta版,也许有所出入吧。

  1. seajs中的require运行就像js中var申明,所谓的代码提升,不管这段代码发在什么地方,require都会先运行,所以这样是无效的,require总是会把所需要的js引入的: try { JSON; } catch (e ) { require("libs/json2"); }
  2. 在定义一个module时,定义依赖关系,最好使用require,只有在某些情况(必须需要条件判断)才使用module.load来根据某种事件来执行某个js文件,例如此处
  3. seajs最好放在js文件根目录上,这样好处理依赖关系,并且可以使用相对路径
  4. 最后,虽然seajs约定所有变量输出需要使用module.exports,但是,有些时候,如果必须打破约定,就直接忽视约定吧。规矩是人定的,如何使用才是最重要的。这里使用ckeditor就只能把CKEDITOR释放了。这里使用json2.js,都没有define直接用module.load('libs/json')了(额,我也刚发现,这样也能用)。 对于Backbone,我觉得如果是简单的模型数据操作,最重要的模块是View。通常模型只需要定义一下url属性和validate方法即可(例如)。url定义当模型调用fetch或者save方法时,向哪一个url发送请求,而validate则是定义save模型属性时,会执行validate方法,如果validate返回错误信息,则save或者set方法将返回error,然后触发error事件,再然后,我们想怎么样就怎么样啦,我们只需要关注发生什么事情了,这就是基于事件模型的js的巨大优势了。Model主要负责数据的操作,从后端返回的JSON对象的所有属性会被copy到Model上,这里,需要非常小心的是,Backbone使用了JSON对象,而JSON全局对象在IE 7中没有,所以只能调用老道的json.js。为了不至于报错(关于js对象判断,参考此文),我使用了自认为很酷的。
try {
  JSON;
}catch (e){
  module.load('lib/json'); //加载json2.js
}

对于controller,如果和我一样使用PHP,则需要配置Backbone.emulateHTTP = true;Backbone.emulateJSON = true;并且,所有的路由必须在Backbone.history.start();运行以后才能开始追踪url改变的事件。

关于用户体验

再补充一些关于用户体验的,关于什么是用户体验,每个角色都会有不同的定义,视觉注重美观,而前端的我们呢。刚刚做这个项目时,我最想尝试使用炫酷的技术,那是用户体验的内容吗?在测验中,需要做选择,最初,我使用的是推拽,花了好多时间解决那些选项扰人的关系,拖拽了的是被选中的还是未选中的,拖拽的目的地是选中还是未选中,晕了半天终于搞定了。第二天,看看觉得拖拽实在麻烦,当选择过多时还得提示,怎么提示呢——使用弹窗,使用jQuery UI的Dialog可以华丽的跳出来,然后绚烂地离开。但事实上,这些华丽却阻止了用户的行为,过大的干扰,和直接使用alert没有多少差别。

想想还是尽可能对用户少一些干扰好吧。如此,便整体上重构代码,把花了大半天实现的拖拽效果和弹窗提示并禁止继续选择的代码全部删掉。把所有的选择项目由achor标签改为checkbox,看了很久觉得有些舍不得,但还是都改了,做了一个飞跃的效果(选中一个则从下面的box中跳出到上面,反之亦然)——程序员总是忘不了炫耀自己的技术,尤其是前端。代码简单了,我却有些甘心了。

到了今天终于发布了,拿到班上进行测验。班上同学都非常支持,尤其是女生,刚发有动作了,非常感谢她们。然后,有人发来回馈,找名字找得好累啊,把字调大一些吧。最后,同学说,一个东西跑了其他得都需要往前跳一个位置,眼花缭乱的,干脆把跳跃的效果也删了。这时候Backbone的MVC威力大显,只需要稍微修改一些html模板,然后删除跳跃动画的函数就行了。小小的删除,却删去我费了好多精力写的代码。但是,用户体验与程序员的技术无关啊,我需要的自己的东西给用户使用,这才是真正用户体验所要关注的吧,很多时候使用简单的方法实现就好,关键是有效吧。

结束语

转眼一个下午过去了啊,暂时就写到这里吧,以后再补充。最后发一下发布线上的地址,使用测试账户可以登陆玩玩,用户名从被试1-被试31,密码是全拼。