Skip to content

Backbone View类自动转React类的研究 #27

@abeet

Description

@abeet

思路:我们知道Uglifyjs可以混淆代码,那么它必然有一个js的解析器,并能对解析后的AST树进行调整,再用js生成器输出为js代码。同样的Babel作为一个强大的js预处理工具,它也必然有一个js的解析器,并能对解析后的AST树进行调整,再用js生成器输出为js代码。

在Uglifyjs的官网上示意了Uglifyjs的解析器的使用,
JS解析器为UglifyJS.parseJS代码生成器为UglifyJS.parse("").print_to_string()UglifyJS.parse("").print(UglifyJS.OutputStream())
写测试代码如下

var UglifyJS = require('uglify-js')
var code = 'function foo() {\n\
  function x(){}\n\
  function y(){}\n\
}\n\
function bar() {}'
var ast = UglifyJS.parse(code)
var walker = new UglifyJS.TreeWalker(function (node) {
  console.log(node.print_to_string());
  if (node instanceof UglifyJS.AST_Defun) {
    // string_template is a cute little function that UglifyJS uses for warnings
    console.log(
      UglifyJS.string_template('Found function {name} at {line},{col}', {
        name: node.name.name,
        line: node.start.line,
        col: node.start.col
      })
    )
  }
})
ast.walk(walker)

感觉得到的AST树结构不那么好理解和操作,是否有更好理解和操作的AST树,看看Babel吧
在Babel的项目 https://github.com/babel/babel 中发现它使用的解析器是 babylon,生成器是babel-generator
进入到Babylon项目 https://github.com/babel/babylon 发现它是基于 acorn 扩展的,并提到它的 AST 又扩展了 ESTree 标准。
进入 Acorn 项目 https://github.com/ternjs/acorn 发现它的示例代码中用到的生成器是 Escodegen 。
进入 Escodegen 项目 https://github.com/estools/escodegen 知道可以把 Mozilla's Parser API 格式的AST 生成js代码,在它的示例中用的js解析器是 esprima
进入到 esprima 项目 https://github.com/jquery/esprima 没有新的发现

在网上搜索 这几个关键词,找到一篇文章
babel/babel#3921
又知道了另外一个解析器及生成器,

综上,我们可以得到几种js解析为ast及ast生成js的几组工具。
1、UglifyJS 自带解析器、遍历器、生成器
2、babylon + babel-traverse + babel-generator
3、babylon +babylon-to-espree+escodegen
4、acorn + escodegen
5、esprima +estraverse+ escodegen
6、shift-parser + shift-codegen
可能还有
代码生成器 patrisika 没有再细究了。

看来 Uglifyjs 是较少被使用的,可能因为它不是ESTree标准
解析器中
acorn 是最快的
babylon 是最慢的
生成器中
shift-codegen 是最快的
babel-generator 是最慢的

https://zhuanlan.zhihu.com/p/21620242
SiZapPaaiGwat/inhuman-cpc.github.io#106
http://wwsun.github.io/posts/javascript-ast-tutorial.html
Parser建议从Esprima开始学习,相比较于其它Parser文档和示例更加丰富和形象。
另外还可以使用 Estraverse https://github.com/estools/estraverse 来作节点的遍历和转换
esprima在线工具
http://esprima.org/demo/parse.html
从这篇文章中提到的 https://astexplorer.net/ 网站中发现好多种JS解析器
acorn acorn-to-esprima babylon babylon6 esformatter-parser espree esprima flow recast shift traceur typescript uglify-js

从文章 http://tech.meituan.com/abstract-syntax-tree.html

https://github.com/sinolz/amd2cmd/blob/master/src/AMD2CMDTransformer.js
还有
http://www.cnblogs.com/ziyunfei/p/3183325.html
会发现,代替更改的一般方式是对最终代码的替换

var U2 = require("uglify-js");
var parseint_nodes = [];
if (node instanceof U2.AST_Call
    && node.expression.print_to_string() === 'parseInt'
    && node.args.length === 1) {
    parseint_nodes.push(node);
}
for (var i = parseint_nodes.length; --i >= 0;) {
    var node = parseint_nodes[i];
    var start_pos = node.start.pos;
    var end_pos = node.end.endpos;
    node.args.push(new U2.AST_Number({
        value: 10
    }));
    var replacement = node.print_to_string({ beautify: true });
    var start_pos = node.start.pos;
    var end_pos = node.end.endpos;
    code = code.substr(0, start_pos) + replacement + code.substr(end_pos);
}
var parse = require("acorn").parse;
var result = new StringEditor(this.content);//注意,这个StringEditor类的replace不是即时替换,调用toString后才替换
var defineExps=[]
if (expression.type === 'CallExpression' &&
    expression.callee.name === 'define') {
    defineExps.push(exps);
}
for (const exp of defineExps) {
    result.replace(exp.start, exp.end, content.substring(exp.start, exp.end)
              .replace(/return/, 'module.exports =')));
}
result.toString();
var collectedDatas = [];
if (comment.range[0] == commentRange[0] && comment.range[1] == commentRange[1]) {
    var commentSourceRange = [commentRange[0] + 2, commentRange[1] - 2];
    var commentSource = source.slice(commentSourceRange[0], commentSourceRange[1]);
    var escapedCommentSource = ("'" + commentSource.replace(/(?=\\|')/g, "\\") + "'").replace(/\s*^\s*/mg, "");
    collectedDatas.push({
        range: heredocCallExpression.range,
        replaceString: escapedCommentSource
    });
}
for (var i = collectedDatas.length - 1; i >= 0; i--) { //从后往前修改输入源码,就可以不用考虑偏移量的问题了
    var range = collectedDatas[i].range;
    var replaceString = collectedDatas[i].replaceString;
    source = source.slice(0, range[0]) + replaceString + source.slice(range[1]);
}

acorn acorn-to-esprima babylon babylon6 esformatter-parser espree esprima flow recast shift traceur typescript uglify-js

对这几个解析器解析出的AST再作观察,

TodoView=BackboneView()

又以acorn和esprima可以配置让得到的AST数据比较简洁
其他解析器位置都存在range属性中,acorn可以设置为存在range属性同时存在start和end属性上,
acorn解析出的AST

{
  "type": "Program",
  "start": 0,
  "end": 23,
  "body": [
    {
      "type": "ExpressionStatement",
      "start": 0,
      "end": 23,
      "expression": {
        "type": "AssignmentExpression",
        "start": 0,
        "end": 23,
        "operator": "=",
        "left": {
          "type": "Identifier",
          "start": 0,
          "end": 8,
          "name": "TodoView"
        },
        "right": {
          "type": "CallExpression",
          "start": 9,
          "end": 23,
          "callee": {
            "type": "Identifier",
            "start": 9,
            "end": 21,
            "name": "BackboneView"
          },
          "arguments": []
        }
      }
    }
  ],
  "sourceType": "module"
}

esprima解析出的AST

{
  "type": "Program",
  "body": [
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "AssignmentExpression",
        "operator": "=",
        "left": {
          "type": "Identifier",
          "name": "TodoView",
          "range": [
            0,
            8
          ]
        },
        "right": {
          "type": "CallExpression",
          "callee": {
            "type": "Identifier",
            "name": "BackboneView",
            "range": [
              9,
              21
            ]
          },
          "arguments": [],
          "range": [
            9,
            23
          ]
        },
        "range": [
          0,
          23
        ]
      },
      "range": [
        0,
        23
      ]
    }
  ],
  "sourceType": "module",
  "range": [
    0,
    23
  ]
}

到时此我们可以定下要使用到的库
即只用 acorn 遍历有它自己的 acorn.walk 方法,因为使用字符替换,也就不需要用到遍历器了。

先转换作代码转换测试,找一两个典型性的,功能类似的的,分别用Backbone和React实现过的代码
http://todomvc.com/examples/backbone/
http://todomvc.com/examples/react/
下载里面涉及的源码
以下将把backbone写的view类todo-view.js转为React写的todoItem.js

/*global Backbone, jQuery, _, ENTER_KEY, ESC_KEY */
var app = app || {};

(function ($) {
	'use strict';

	// Todo Item View
	// --------------

	// The DOM element for a todo item...
	app.TodoView = Backbone.View.extend({
		//... is a list tag.
		tagName:  'li',

		// Cache the template function for a single item.
		template: _.template($('#item-template').html()),

		// The DOM events specific to an item.
		events: {
			'click .toggle': 'toggleCompleted',
			'dblclick label': 'edit',
			'click .destroy': 'clear',
			'keypress .edit': 'updateOnEnter',
			'keydown .edit': 'revertOnEscape',
			'blur .edit': 'close'
		},

		// The TodoView listens for changes to its model, re-rendering. Since
		// there's a one-to-one correspondence between a **Todo** and a
		// **TodoView** in this app, we set a direct reference on the model for
		// convenience.
		initialize: function () {
			this.listenTo(this.model, 'change', this.render);
			this.listenTo(this.model, 'destroy', this.remove);
			this.listenTo(this.model, 'visible', this.toggleVisible);
		},

		// Re-render the titles of the todo item.
		render: function () {
			// Backbone LocalStorage is adding `id` attribute instantly after
			// creating a model.  This causes our TodoView to render twice. Once
			// after creating a model and once on `id` change.  We want to
			// filter out the second redundant render, which is caused by this
			// `id` change.  It's known Backbone LocalStorage bug, therefore
			// we've to create a workaround.
			// https://github.com/tastejs/todomvc/issues/469
			if (this.model.changed.id !== undefined) {
				return;
			}

			this.$el.html(this.template(this.model.toJSON()));
			this.$el.toggleClass('completed', this.model.get('completed'));
			this.toggleVisible();
			this.$input = this.$('.edit');
			return this;
		},

		toggleVisible: function () {
			this.$el.toggleClass('hidden', this.isHidden());
		},

		isHidden: function () {
			return this.model.get('completed') ?
				app.TodoFilter === 'active' :
				app.TodoFilter === 'completed';
		},

		// Toggle the `"completed"` state of the model.
		toggleCompleted: function () {
			this.model.toggle();
		},

		// Switch this view into `"editing"` mode, displaying the input field.
		edit: function () {
			this.$el.addClass('editing');
			this.$input.focus();
		},

		// Close the `"editing"` mode, saving changes to the todo.
		close: function () {
			var value = this.$input.val();
			var trimmedValue = value.trim();

			// We don't want to handle blur events from an item that is no
			// longer being edited. Relying on the CSS class here has the
			// benefit of us not having to maintain state in the DOM and the
			// JavaScript logic.
			if (!this.$el.hasClass('editing')) {
				return;
			}

			if (trimmedValue) {
				this.model.save({ title: trimmedValue });
			} else {
				this.clear();
			}

			this.$el.removeClass('editing');
		},

		// If you hit `enter`, we're through editing the item.
		updateOnEnter: function (e) {
			if (e.which === ENTER_KEY) {
				this.close();
			}
		},

		// If you're pressing `escape` we revert your change by simply leaving
		// the `editing` state.
		revertOnEscape: function (e) {
			if (e.which === ESC_KEY) {
				this.$el.removeClass('editing');
				// Also reset the hidden input back to the original value.
				this.$input.val(this.model.get('title'));
			}
		},

		// Remove the item, destroy the model from *localStorage* and delete its view.
		clear: function () {
			this.model.destroy();
		}
	});
})(jQuery);
/*jshint quotmark: false */
/*jshint white: false */
/*jshint trailing: false */
/*jshint newcap: false */
/*global React */
var app = app || {};

(function () {
	'use strict';

	var ESCAPE_KEY = 27;
	var ENTER_KEY = 13;

	app.TodoItem = React.createClass({
		handleSubmit: function (event) {
			var val = this.state.editText.trim();
			if (val) {
				this.props.onSave(val);
				this.setState({editText: val});
			} else {
				this.props.onDestroy();
			}
		},

		handleEdit: function () {
			this.props.onEdit();
			this.setState({editText: this.props.todo.title});
		},

		handleKeyDown: function (event) {
			if (event.which === ESCAPE_KEY) {
				this.setState({editText: this.props.todo.title});
				this.props.onCancel(event);
			} else if (event.which === ENTER_KEY) {
				this.handleSubmit(event);
			}
		},

		handleChange: function (event) {
			if (this.props.editing) {
				this.setState({editText: event.target.value});
			}
		},

		getInitialState: function () {
			return {editText: this.props.todo.title};
		},

		/**
		 * This is a completely optional performance enhancement that you can
		 * implement on any React component. If you were to delete this method
		 * the app would still work correctly (and still be very performant!), we
		 * just use it as an example of how little code it takes to get an order
		 * of magnitude performance improvement.
		 */
		shouldComponentUpdate: function (nextProps, nextState) {
			return (
				nextProps.todo !== this.props.todo ||
				nextProps.editing !== this.props.editing ||
				nextState.editText !== this.state.editText
			);
		},

		/**
		 * Safely manipulate the DOM after updating the state when invoking
		 * `this.props.onEdit()` in the `handleEdit` method above.
		 * For more info refer to notes at https://facebook.github.io/react/docs/component-api.html#setstate
		 * and https://facebook.github.io/react/docs/component-specs.html#updating-componentdidupdate
		 */
		componentDidUpdate: function (prevProps) {
			if (!prevProps.editing && this.props.editing) {
				var node = React.findDOMNode(this.refs.editField);
				node.focus();
				node.setSelectionRange(node.value.length, node.value.length);
			}
		},

		render: function () {
			return (
				<li className={classNames({
					completed: this.props.todo.completed,
					editing: this.props.editing
				})}>
					<div className="view">
						<input
							className="toggle"
							type="checkbox"
							checked={this.props.todo.completed}
							onChange={this.props.onToggle}
						/>
						<label onDoubleClick={this.handleEdit}>
							{this.props.todo.title}
						</label>
						<button className="destroy" onClick={this.props.onDestroy} />
					</div>
					<input
						ref="editField"
						className="edit"
						value={this.state.editText}
						onBlur={this.handleSubmit}
						onChange={this.handleChange}
						onKeyDown={this.handleKeyDown}
					/>
				</li>
			);
		}
	});
})();

打开 https://astexplorer.net/
https://github.com/ternjs/acorn
备查,开始写代码
第一个目标
Backbone.View.extend(
替换为
React.createClass(

var acorn = require('acorn')
var walk = require('acorn/dist/walk')
var fs = require('fs')
var file = 'backbone/todo-view.js'
var source = fs.readFileSync(file)
var ast = acorn.parse(source)
var collectedDatas = []
walk.simple(ast, {
  CallExpression: function (node) {
    // console.log(node)
    if (
      node.callee.property &&
      node.callee.property.name === 'extend' &&
      node.callee.object.property.name === 'View' &&
      node.callee.object.object.name === 'Backbone'
    ) {
      collectedDatas.push({
        start: node.callee.start,
        end: node.callee.end,
        newCode: 'React.createClass'
      })
    }
  }
})
for (var i = collectedDatas.length - 1; i >= 0; i--) {
  // 从后往前修改输入源码,就可以不用考虑偏移量的问题了
  var start = collectedDatas[i].start
  var end = collectedDatas[i].end
  var newCode = collectedDatas[i].newCode
  source = source.slice(0, start) + newCode + source.slice(end)
}
console.log(source)

替换成功,可以进行更多的处理了。
在最后作字符的替换时要求collectedDatas中要处理的位置是严格按照先后顺序的,这一点处理复杂情况时显然无法保证,只所以封装了一个字符串处理类。

/**
 * 字符串操作类,在调用replace时不会真正的替换,而只是记录要替换的位置,在调用toString时才进行替换,并返回替换后的新的字符串
 * @param {String} content
 * @param {Number} [start=0]
 * @param {Number} [end=content.length]
 */

var StringEditor = function (content, start, end) {
  this.content = content
  this.start = start || 0
  this.end = end || content.length
  this.replaceActions = []
}
StringEditor.prototype = {
  /**
   * 记录要替换的字符的位置和新字符串
   * @param {Number} start
   * @param {Number} end
   * @param {String|StringEditor} newContent
   */
  replace: function (start, end, newContent) {
    this.replaceActions.push({
      range: [start, end],
      newContent
    })
  },
  /**
   * 同String.prototype.slice
   * @param {Number} start
   * @param {Number} end
   * @param {String} content
   */
  slice: function (start, end) {
    return this.content.slice(start, end)
  },
  /**
   * 完成字符串替换并返回结果
   * @returns {String} newContent
   */
  toString: function () {
    var value = ''
    var start = this.start
    this.replaceActions = this.replaceActions.sort(function (a, b) {
      return a.range[0] - b.range[0]
    })
    for (var action of this.replaceActions) {
      value += this.content.substring(start, action.range[0])
      value += action.newContent.toString()
      start = action.range[1]
    }

    value += this.content.substring(start, this.end)
    return value
  }
}

涉及到lodash模板转为jsx,需要引入一个不依赖bom的html操作工具,
网上搜索一下,找到一个12034个star的项目 https://github.com/cheeriojs/cheerio
在转换lodash到jsx中发现cheerio有一个问题,期望得到

<li class={classNames({completed:this.props.model.get('completed')})}>

实际得到

<li class="{classNames({completed:this.props.model.get(&apos;completed&apos;)})}">

经翻源代码发现拼接html的方法位于node_modules\dom-serializer\index.js
对个js文件中的formatAttrs()作调整,

    if (!value && booleanAttributes[key]) {
      output += key;
    } else {
      output += key + '="' + (opts.decodeEntities ? entities.encodeXML(value) : value) + '"';
    }

改为

    if (!value && booleanAttributes[key]) {
      output += key;
    } else if(opts.jsx && value.startsWith('{') && value.endsWith('}')) {
      output += key + '=' + value;
    } else {
      output += key + '="' + (opts.decodeEntities ? entities.encodeXML(value) : value) + '"';
    }

对个js文件中的renderText()作调整,

  if (opts.decodeEntities && !(elem.parent && elem.parent.name in unencodedElements)) {
    data = entities.encodeXML(data);
  }

改为

  if (opts.decodeEntities && !(elem.parent && elem.parent.name in unencodedElements) && !opts.jsx) {
    data = entities.encodeXML(data);
  }

加配置项{jsx:true}时,以{开始、以}结束的属性,不用双引号引起来,并具不对其中的特殊符进行编码。

终于成功将一个BackboneView类转为React类,但其中写了很多特例处理,代码并不通用,据此得出的结论是,Backbone View类自动转React类可以有限地减少手工修改的工作量(30%~60%)但无法代替手工修改

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions