Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
abeet opened this issue Dec 5, 2017 · 0 comments
Open

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

abeet opened this issue Dec 5, 2017 · 0 comments

Comments

@abeet
Copy link
Owner

abeet commented Dec 5, 2017

思路:我们知道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%)但无法代替手工修改

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant