1. MV*的理解

2. Vue简介

3. Vue vs Regular

4. 在数据营销系统中的实践

MV*?

M:Model,数据模型

V:View,呈现在用户眼前的视图

*:用来连接M和V

MVC

MVC (Model-View-Controller) 是一种通过关注点分离优化项目结构的架构模式,它通过第三方(Controllers)强制把业务数据(Models)和交互界面(Views)分离。

  • 域元素(domain-element)被称作Model,不关心交互界面(Views和Controllers)
  • 呈现内容由Views和Controllers负责,并且不是只有一个View和Controller。每个呈现在屏幕上的元素都需要View-Controller,所以它们之间没有真正的分离。
  • 在View-Controller对中,Controller用于处理用户输入,并进行合理的处理。
  • View依赖观察者模式在Model改变时更新视图。(Observer)

var PhotoView = Backbone.View.extend({
    tagName:  "li",
    template: _.template($('#photo-template').html()),
    events: {
      "click img" : "toggleViewed"
    },
    initialize: function() {
      // Observer pattern
      _.bindAll(this, 'render');
      this.model.bind('change', this.render);
      this.model.bind('destroy', this.remove);
    },

    // Re-render the photo entry
    render: function() {
      $(this.el).html(this.template(this.model.toJSON()));
      return this;
    },
    toggleViewed: function() {
      this.model.viewed();
    }

});

Model 2

// routes.index.js
router.get('/setting/personal', function(req, res){
  // Model
  Account.find({account: req.session.account.account}, function(err, account){
    if(err){
      console.log(err);
    }else{
      Team.find({id: {$in: account[0].teams}}, function(err, teams){
        if(err){
          console.log(err);
        }else{
          // View
          res.render('personal', {
            account: account[0],
            teams: teams
          });
        }
      });
    }
  });
});
// models/teamModel.js
var mongoose = require('mongoose');

var teamSchema = require('../schemas/teamSchema');

var Team = mongoose.model('team', teamSchema);

Team.Clear = function(){
    Team.remove({members: []}, function(err, res){
        if(err){
            console.log(err);
        }else{
            if(res.result.ok == 1){
                console.log("cleared the empty teams...");
            }else{
                console.log("no empty teams :)");
            }
        }
    });
}

module.exports = Team;

MVC带来了什么?

  • 提高整体可维护性. 当应用需要更新时,我们可以很清楚地知道是去修改数据模型,控制器,还是修改视图。
  • View和Model解耦更利于对逻辑进行单元测试。
  • 减少重复的底层Model和Controller代码。
  • 根据应用大小和角色分工,这种模块化的方式能让负责核心逻辑和交互界面的开发者同时工作。

MVVM

Model–View–ViewModel也被称作model–view–binder

Model:表示真实状态内容的域模型

View: 同MVC种的View,是呈现在用户眼前的结构布局

ViewModel:ViewModel是暴露公共属性和指令的View的抽象。不像MVC中的Controller,MVVM有一个binder,在ViewModel中,binder协调View和data binder的通信。

MVVM支持View和View-Model之间的双向绑定,允许View-Model中的状态变化自动更新到View上。View-Model也是利用观察者模式将变化通知到View。

优点:

缺点:

 如果数据绑定过多需要消耗更多内存。

数据驱动,正确的数据 === 正确的视图。

可测试性,独立针对vm测试。

Vue.js

The Progressive Javascript Framework

声明式渲染

// index.html
<script src="path/to/vue.js"></script>

<div id="app">
    {{message}}
</div>

// index.js
var app = new Vue({
    el: '#app',
    data: {
        message: 'Hey~'
    },
    props: {...},
    methods: {...},
    computed: {...},
    watches: {...},
    filters: {...},
    components: {...}
})

// render to
<div id="app">
    Hey~
</div>

模板语法

// 最基本的数据绑定使用mustache语法,也可以用v-text指令,
<span>Message: {{ msg }}</span>
// 等同于
<span v-text="'Message: ' + msg"></span>

仅绑定一次使用v-once指令
<span v-once>This will never change: {{ msg }}</span>

// 绑定原始HTML用v-html指令
<div v-html="rawHtml"></div>

// 可使用filter处理数据
<span>Message: {{ msg | formatter }}</span>  

// 可以绑定其他属性
<div v-bind:id="dynamicId"></div>
// shorthand
<div :id="dynamicId"></div>

// 绑定事件
<a v-on:click="doSomething">
// shorthand
<a @click="doSomething">
// 事件支持修饰符
<form @submit.prevent="doSubmit">

ViewModel Object

const vm = new Vue({
    el: '#app',
    data: {
        innerState: ''
    },
    // Only accepts Function when used in a component definition.
    data() {
        return {}
    },
    props: {
        id: { 
            type: String,
            default: ''
            // Only accepts Function when used in a component definition.
            default() {
                return {}
            }
        }
    },
    computed: {
        stateInfo() {
            return this.innerState ? `State: ${this.innerState}` : ''
        }
    },
    methods: {
        handleSubmit() {
            this.$http.post('/api/submit')
        }
    },
    watch: {
        innerState(value, oldValue){}
    },
    created() {
        this.$http.get('/api/list')
    },
    mounted() {
        this.$refs.input.focus()
        this.$refs.submit.addEventListener('click', this.handleSubmit)
    },
    beforeDestroy() {
        this.$refs.submit.removeEventListener('click', this.handleSubmit)
    }
})
// parent.vue
<div>
    <child-component :info="content"></child-component>
</div>

<script>
import childComponent from 'childComponent'
export default {
    data() {
        return {
            content: 'content from parent'
        }
    },
    components: { childComponent }
}
</script>
// childComponent.vue
<template>
    <h1>{{info}}</h1>
</template>

<script>
export default{
    name: 'child',
    props: {
        info: {
            type: String,
            default: ''
        }
    }
}
</script>

Class and Style Binding

<div v-bind:class="classObject"></div>

data: {
  classObject: {
    active: true,
    'text-danger': false
  }
}

// or
computed: {
  classObject: function () {
    return {
      active: this.isActive && !this.error,
      'text-danger': this.error && this.error.type === 'fatal',
    }
  }
}

<div v-bind:class="[activeClass, errorClass]">

data: {
  activeClass: 'active',
  errorClass: 'text-danger'
}

<div class="active text-danger"></div>

// style binding
<div v-bind:style="styleObject"></div>

data: {
  styleObject: {
    color: 'red',
    fontSize: '13px'
  }
}

条件渲染 & 列表渲染

<div v-if="type === 'A'">
  A
</div>
<div v-else-if="type === 'B'">
  B
</div>
<div v-else-if="type === 'C'">
  C
</div>
<div v-else>
  Not A/B/C
</div>

<div v-show="type === 'A'">
  A
</div>
<ul id="example-1">
  <!-- item -->
  <li v-for="item in items">
    {{ item.message }}
  </li>
</ul>

<!-- item, index -->
<li v-for="(item, index) in items">
    {{ parentMessage }} - {{ index }} - {{ item.message }}
</li>

<!-- of also work -->
<div v-for="item of items"></div>

var example1 = new Vue({
  el: '#example-1',
  data: {
    items: [
      { message: 'Foo' },
      { message: 'Bar' }
    ]
  }
})

<ul id="repeat-object" class="demo">
  <li v-for="value in object">
    {{ value }}
  </li>
</ul>

new Vue({
  el: '#repeat-object',
  data: {
    object: {
      firstName: 'John',
      lastName: 'Doe',
      age: 30
    }
  }
})

=> John, Done, 30

<div v-for="(value, key, index) in object">
  {{ index }}. {{ key }} : {{ value }}
</div>

Vue

Global Config (Vue.config.xxx)

slient, devtools, keyCodes...

Global API (Vue.xxx)

extend, nextTick, set, delete, directive, filter, mixin...

Options/data

data, props, computed, methods, watch, el, template...

Lifecycle Hooks

beforeCreate, created, mounted, update, destroy...

Options/Assets

directives, filters, components

Optiosn/Misc

name, delimiters, inheritAttrs, comments

Instance Methods/Events(vm.xxx)

$watch, $set, $delete, $on, $once, $off, $emit

Directives(v-xxx)

text, html, model, show, if, else, else-if, for, on, bind...

Special Attributes

key, ref, slot, is

1. config配置一般不需要改动

2. Global API一般不会用到,因为每个api对应在实例中都有一个对应实现。使用this.$set而不是Vue.set。

3. el, template, render三选一,使用.vue文件开发用template更方便。

4. 生命周期就create, mounted, destroy比较常用。

Slot & is

// my-component.vue
<div>
  <h2>I'm the child title</h2>
  <slot>
    This will only be displayed if there is no content
    to be distributed.
  </slot>
</div>

// parent.vue
<div>
  <h1>I'm the parent title</h1>
  <my-component>
    <p>This is some original content</p>
    <p>This is some more original content</p>
  </my-component>
</div>

// output
<div>
  <h1>I'm the parent title</h1>
  <div>
    <h2>I'm the child title</h2>
    <p>This is some original content</p>
    <p>This is some more original content</p>
  </div>
</div>
// my-component.vue
<div>
  <h2>I'm the child title</h2>
  <slot>
    This will only be displayed if there is no content
    to be distributed.
  </slot>
</div>

// parent.vue
<div>
  <h1>I'm the parent title</h1>
  <my-component>
  </my-component>
</div>

// output
<div>
  <h1>I'm the parent title</h1>
  <div>
    <h2>I'm the child title</h2>
    This will only be displayed if there is no content
    to be distributed.
  </div>
</div>
<template>
  <transition name="dialog-fade">
    <div class="el-dialog__wrapper" v-show="visible" @click.self="handleWrapperClick">
      <div
        class="el-dialog"
        :class="[sizeClass, customClass]"
        ref="dialog"
        :style="style">
        <div class="el-dialog__header">
          <!-- title slot -->
          <slot name="title">
            <span class="el-dialog__title">{{title}}</span>
          </slot>
          <button type="button" class="el-dialog__headerbtn" aria-label="Close" 
                  v-if="showClose" @click="handleClose">
            <i class="el-dialog__close el-icon el-icon-close"></i>
          </button>
        </div>
        <!-- default slot -->
        <div class="el-dialog__body" v-if="rendered"><slot></slot></div>
        <div class="el-dialog__footer" v-if="$slots.footer">
          <slot name="footer"></slot>
        </div>
      </div>
    </div>
  </transition>
</template>

<el-dialog
  title="提示"
  :visible.sync="dialogVisible"
  size="tiny"
  :before-close="handleClose">
  <span>这是一段信息</span>
  <span slot="footer" class="dialog-footer">
    <el-button @click="dialogVisible = false">取 消</el-button>
    <el-button type="primary" @click="dialogVisible = false">确 定</el-button>
  </span>
</el-dialog>
var vm = new Vue({
  el: '#example',
  data: {
    currentView: 'home'
  },
  components: {
    home: { /* ... */ },
    posts: { /* ... */ },
    archive: { /* ... */ }
  }
})

<component v-bind:is="currentView">
  <!-- component changes when vm.currentView changes! -->
</component>

is

单文件组件

<template lang="html">
  <div id="app">
    {{title}}
    <child-component :content="info"></child-component>
  </div>
</template>

<script>
import ChildComponent from 'Child'

export default {
  data() {
    return { 
      title: 'This is title',
      info: 'Info content'
    }
  },
  components: {
    ChildComponent
  }
}
</script>
<style lang="css" scoped>
#app{
  margin: 10px;
}
</style>

不喜欢混在一起可以用src将各个部分分离。

方便管理,一个文件就是一个独立的组件。

不用拼接模板字符串,可读性更好。

对预处理语言支持度好。

可能造成单一文件内容过多。

配合babel使用ES next 编写。

Router & Vuex

// 0. 如果使用模块化机制编程,導入Vue和VueRouter,
// 要调用 Vue.use(VueRouter)

// 1. 定义(路由)组件。
// 可以从其他文件 import 进来
const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }

// 2. 定义路由
// 每个路由应该映射一个组件。 其中"component" 可以是
// 通过 Vue.extend() 创建的组件构造器,
// 或者,只是一个组件配置对象。
// 我们晚点再讨论嵌套路由。
const routes = [
  { path: '/foo', component: Foo },
  { path: '/bar', component: Bar }
]

// 3. 创建 router 实例,然后传 `routes` 配置
// 你还可以传别的配置参数, 不过先这么简单着吧。
const router = new VueRouter({
  routes // (缩写)相当于 routes: routes
})

// 4. 创建和挂载根实例。
// 记得要通过 router 配置参数注入路由,
// 从而让整个应用都有路由功能
const app = new Vue({
  router
}).$mount('#app')

// 现在,应用已经启动了!
const store = new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
  	increment: state => state.count++,
    decrement: state => state.count--
  }
})

new Vue({
  el: '#app',
  computed: {
    count () {
	    return store.state.count
    }
  },
  methods: {
    increment () {
      store.commit('increment')
    },
    decrement () {
    	store.commit('decrement')
    }
  }
})

// 在模块化开发时,需要调用Vue.use(),并将store注入Vue实例,然后可以通过$store调用。
Vue.use(Vuex);
vm = new Vue({
    store
})

vm.$store.commit('increment')



Vue & Regular

// 都支持大部分Js表达式

<span>{ variable }</span>

<span>{ variable + '.' }</span>

<span>{ condition ? trueValue : falseValue }</span>

// filter 通过 | 使用,可以链式调用
<span>{ variable | filter1 | filter2 }</span>

// 单次绑定语法不同
// Regular
<span>{ @(variable) }</span>
// Vue
<span v-once>{ variable }</span>

插值语法异同

// Regular的模板插值中有容错机制,不存在的属性会用undefined替代
<div>{this.methodNoFound(blog.user.name)}</div>

// this是指向组件的,但是方法调用需要显示写this.xxx
<custom-pager attr1={user} attr2=user on-nav={this.nav()}></custom-pager>

// 可以使用#include插入其他内容
<div class="modal-body">
  {#include content }
</div>

// 循环语法和if, else判断都有{#list}, {#if}这样的block包裹
{#list items as item}
    <span class='index'>{item_index}:{item}</span>
{/list}

{#if user.age >= 80 }
  you are too old
{#elseif user.age <= 10}
  you are too young
{#else}
  Welcome, Friend
{/if}

Regular

// 支持自增,自减,但是Vue会提示存在无限循环,并执行100次后强制终止
<span>{{ counter++ }}</span>

// 支持位运算
<span>{{ 100 & 111 }}</span>
<span>{{ (100 | 111) }}</span>

// 支持正则表达式字面量
<span>{{ /a/.test('abc') }}</span> 

// 属性,方法都是自动绑定在vm上的,调用时只需要写方法名
<button @click="doSubmit">Submit</button>

// 循环,条件判断都是基于指令的
<ul>
    <li v-for="i in 3">{{i}}</li>
</ul>

<span v-if="condition">true</span>
<span v-else>else</span>

Vue

都有Lifecycle的概念,可以在不同阶段做不同的事情。

Regular暴露了config, init, destory三个生命周期函数,一般是在config阶段初始化data,init阶段发生在compile之后,所以可以通过$refs获取到DOM,但是仍未插入到页面中,无法通过选择器获取。

var Component = Regular.extend({
  template: 
    `
    <div><h2 id="u" ref="u" on-click={this.changeTitle(title + '1')}>{title}</h2></div>
    `,
  config: function(data){
    data = { title: 'show' }
  },
  init: function(){
    console.log(document.querySelector('#u'), this.$refs.u)
  },
  changeTitle: function(title){
    this.data.title = title;
  }
})

Vue中暴露了一共10个生命周期函数,比较常用的是Observer Data和init Events完成后就触发的created,将vm.$el替换el完成后执行的mounted,和组件销毁时触发的destroyed。mounted中可以使用选择器获取DOM节点。

原理上的差异

Living Template Engine

Vue 2.x开始引入Virtual DOM的概念,因此在渲染阶段,无论是提供template,还是el,最终都会处理为render函数。

总体评价

Vue:语法更优雅,社区更活跃,功能更强(ssr),更可靠(github上bug更少,维护者更活跃),周边工具链更健壮(cli, ui component, router等),更多可能性(weex)。不支持IE9-。

Regular:支持低版本IE,对猪场内部体系(nej,nei)更友好。但特性相对较少。

Vue in 数据营销系统

vue init webpack bi-pm-sys
.
├── build // 构建脚本
├── config // webpack配置文件
├── mock // mock数据(后期新增)
├── src // 源码文件夹
│   ├── api // api定义文件夹
│   ├── common // 公共utils
│   ├── components //公共组件
│   ├── css // 样式文件
│   ├── page // 每个页面的入口组件
│   │   ├── error
│   │   ├── icon
│   │   ├── label
│   │   ├── program
│   │   └── user_portrait
│   ├── router // vue-router配置
│   └── store // vuex相关
│       └── modules
├── static // 静态资源
│   └── images
│       └── emojis
└── test
  • element-ui
  • axios
  • vue-router
  • vuex
  • lodash
  • moment
  • hicharts
{
  "name": "data-marketing",
  "description": "数据营销系统",
  "scripts": {
    // 正常开发时执行的脚本
    "dev": "node build/dev-server.js",
    // 代理到测试服务器
    "proxy": "npm run dev 10.165.125.220:9999/",
    "analysis": "node build/dev-server.js -a",
    // 生产环境构建
    "build": "node build/build.js",
    "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs",
    "lint:fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs",
    "precommit": "lint-staged"
  },
  "lint-staged": {
    "gitDir": "../",
    "linters": {
      "webapp/src/**/*.{js,vue}": ["eslint --fix", "git add"]
    }
  },
  "dependencies": {
  },
  "devDependencies": {
  },
  "engines": {
  }
}

package.json

build/dev-server.js

var config = require('../config')
// 默认为development模式
if (!process.env.NODE_ENV) {
  process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV)
}

var opn = require('opn')
var path = require('path')
var express = require('express')
var webpack = require('webpack')
var webpackConfig = process.env.NODE_ENV === 'testing'
  ? require('./webpack.prod.conf')
  : require('./webpack.dev.conf')

// default port where dev server listens for incoming traffic
var port = process.env.PORT || config.dev.port
// automatically open browser, if not set will be false
var autoOpenBrowser = !!config.dev.autoOpenBrowser

// 实例化了一个基于express的web server
var app = express()
var compiler = webpack(webpackConfig)

// 使用代理中间件
app.use(require('./dev-proxy')())

// webpack-dev-middleware + webpack-hot-middleware 进行hot module reload
var devMiddleware = require('webpack-dev-middleware')(compiler, {
  publicPath: webpackConfig.output.publicPath,
  quiet: true
})

var hotMiddleware = require('webpack-hot-middleware')(compiler, {
  log: () => {}
})
// force page reload when html-webpack-plugin template changes
compiler.plugin('compilation', function (compilation) {
  compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
    hotMiddleware.publish({ action: 'reload' })
    cb()
  })
})

// handle fallback for HTML5 history API
app.use(require('connect-history-api-fallback')())

// serve webpack bundle output
app.use(devMiddleware)

// enable hot-reload and state-preserving
// compilation error display
app.use(hotMiddleware)

// serve pure static assets
var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory)
app.use(staticPath, express.static('./static'))

// init error cb
app.use(function(error, request, response, next) {
  console.error(error.stack);
  response.status(500).send(error.stack);
})

var uri = 'http://localhost:' + port

devMiddleware.waitUntilValid(function () {
  console.log('> Listening at ' + uri + '\n')
})

module.exports = app.listen(port, function (err) {
  if (err) {
    console.log(err)
    return
  }

  // when env is testing, don't need open it
  if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') {
    opn(uri)
  }
})

build/dev-proxy.js

const fs = require('fs')
const path = require('path')
const isIp = require('is-ip')
const chalk = require('chalk')
const isUrl = require('is-url')
const config = require('../config')
const urlParse = require('url-parse')
const proxyMiddleware = require('http-proxy-middleware')
const getFilePath = require('../mock/mockRouterMap').getFilePath

const initProxyMiddleware = function (toProxyAddress) {
  const proxyTable = config.dev.proxyTable

  var filter = function (pathname) {
    return !!getFilePath(pathname)
  };

  toProxyAddress = proxyTable[toProxyAddress] || toProxyAddress;

  if(!isUrl(toProxyAddress)){
    var urlObj = urlParse(`http://${toProxyAddress}`)
    if(!isIp(urlObj.hostname)){
      return function(){
        console.log('')
        console.log(chalk.yellow(`代理地址:${toProxyAddress} 错误`))
        console.log('')
      }
    }
    toProxyAddress = urlObj.href
  }
  function onProxyRes(proxyRes, req, res) {
  }
  function onProxyReq(proxyReq, req, res) {
  }
  return proxyMiddleware(filter, {
    target: toProxyAddress,
    onProxyRes: onProxyRes,
    onProxyReq: onProxyReq
  })
}

module.exports = function() {
  // 解析命令行参数,判断是否传递了proxy ip
  const processArgv = process.argv.slice(2)
  var index;
  index = processArgv.findIndex(arg => isUrl(`http://${arg}`));
  if (index === -1) {
    return require('../mock')
  } else {
    return initProxyMiddleware(processArgv[index]);
  }
}

build/webpack.base.js

module.exports = {
  entry: {
    app: './src/main.js'
  },
  output: {
    path: config.build.assetsRoot,
    filename: '[name].js',
    publicPath: process.env.NODE_ENV === 'production'
      ? config.build.assetsPublicPath
      : config.dev.assetsPublicPath
  },
  resolve: {
    extensions: ['.js', '.vue', '.json'],
    // https://webpack.js.org/configuration/resolve/#resolve-modules
    modules: [
      resolve('src'),
      resolve('node_modules')
    ]
  },
  module: {
    rules: [
      {
        test: /\.(js|vue)$/,
        loader: 'eslint-loader',
        // check source files, not modified by other loaders like `babel-loader`
        // https://github.com/MoOx/eslint-loader
        enforce: "pre",
        include: [resolve('src'), resolve('test')],
        options: {
          formatter: require('eslint-friendly-formatter')
        }
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: vueLoaderConfig
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        include: [resolve('src'), resolve('test')].concat(
          process.env.NODE_ENV === 'production'
            ? [resolve('node_modules/element-ui/src/utils')]
            : []
        )
      },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        loader: 'url-loader',
        query: {
          limit: 10000,
          name: utils.assetsPath('img/[name].[hash:7].[ext]')
        }
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        loader: 'url-loader',
        query: {
          limit: 10000,
          name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
        }
      }
    ]
  }
}
// src/main.js
import Vue from 'vue';

import ElementUI from 'element-ui';

Vue.use(ElementUI);
import './css/index.css';
import ajax from './api/ajax';

import App from './App';
import router from './router';
import store from './store';
/* eslint-disable no-new */
new Vue({
    el: '#app',
    store,
    router,
    render: h => h(App)
});
// App.vue
<template>
  <router-view></router-view>
</template>

<script>
  export default {};
</script>
// page.vue
<template>
  <div id="app">
    <header-menu></header-menu>
    <div class="g-body" :class="{noHasSideNav: noHasSideNav}">
      <side-nav @toggle="toggleNav"></side-nav>
      <router-view></router-view>
    </div>
  </div>
</template>

页面布局

// page/*.vue
<template>
    <content-layout>
        <template slot="title">title</template>
        <template slot="content">title</template>
    </content-layout>
</template>

心得

 

  • 组件之间使用props传递数据,如果组件嵌套过深再使用vuex,如果是可复用组件不应该用vuex管理状态。
  • 处理vm的data时先拷贝一份,再对拷贝进行操作。(JSON.parse(JSON.stringify(this.data)))。
  • 由于Object.defineProperty的限制,在数据初始化,操作数组和对象时要特别注意。
  • methods可以为async函数。
  • vuex的mutation-types单独放在一个文件。
  • 善用动态组件和slot。
  • store在划分modules建议开启namespaced: true。
  • 未来可以尝试一下使用render function,能充分利用js的强大特性,也能更好地过渡到React。

Reference

END

Made with Slides.com