第2039期最全的Vue3.0升级指南
Posted 前端早读课
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了第2039期最全的Vue3.0升级指南相关的知识,希望对你有一定的参考价值。
前言
有哪些差一点呢?今日早读文章由宇通客车@吕小鸣授权分享。
@吕小鸣,慕课网讲师,《html5移动Web+Vue.js应用开发实现》作者,曾为腾讯前端高级工程师,宇通客车智能网联研究院前端架构师,专注于移动web开发,数据可视化,前端工程化搭建。
正文从这开始~~
本文将会全方位的介绍Vue3的新功能,新优化,新特性,以及升级指南。
由于Vue3正式版还未完全发布,最新的是Release Candidate版本。
Vue3会带来些什么?
更快
更小
更易于维护
新功能和特性
更快
重构了Virtual DOM
Vue3重写了虚拟DOM的实现方法,初始渲染/更新可以提速达100%。
对于Vue2.x版本的虚拟DOM来说,Vue会遍历 <template>
模板中的所有内容,并根据这些标签生成对应的虚拟DOM(虚拟DOM一般指采用key/value对象来保存标签元素的属性和内容),当有内容改变时,遍历虚拟DOM来diff找到对应的标签元素所对应的DOM节点,并改变其内容。例如下面这段内容:
<template>
<div class="content">
<p>number1</p>
<p>number2</p>
<p>number3</p>
<p>{{count}}</p>
</div>
</template>
当触发双向绑定时,遍历所有的 <div>
标签和 <p>
标签,找到 {{count}}
变量对应的 <p>
的DOM节点,并改变其内容。这对于那些纯静态 <p>
的节点进行diff其实是比较浪费资源的,当节点的数量很少时,表现并不明显,但是一旦节点的数量过大,在性能上就会慢很多。对此,Vue3在此基础上进行了优化主要有:
标记静态内容,并区分动态内容。
更新时只diff动态的部分。
针对上面的代码,Vue3中首先会区分出{{count}}这部分动态的节点,在进行diff时,只针对这些节点进行,从而减少资源浪费,提升性能,具体这部分逻辑可以参考源码。
事件缓存
我们知道在vue2中,针对节点绑定的事件,每次触发都要重新生成全新的function去更新。在Vue3中,提供了事件缓存对象cacheHandlers,当cacheHandlers开启的时候,编译会自动生成一个内联函数,将其变成一个静态节点,这样当事件再次触发时,就无需重新创建函数直接调用缓存的事件回调方法即可。
开启cacheHandlers:
import { createVNode as _createVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createBlock as _createBlock } from "vue"
const _hoisted_1 = { id: "app" }
export function render(_ctx, _cache) {
return (_openBlock(), _createBlock("div", _hoisted_1, [
_createVNode("button", {
onClick: _cache[1] || (_cache[1] = $event => (_ctx.confirmHandler($event)))
}, "确认"),
_createVNode("span", null, _toDisplayString(_ctx.vue3), 1 /* TEXT */)
]))
}
关闭cacheHandlers:
import { createVNode as _createVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createBlock as _createBlock } from "vue"
const _hoisted_1 = { id: "app" }
export function render(_ctx, _cache) {
return (_openBlock(), _createBlock("div", _hoisted_1, [
_createVNode("button", { onClick: _ctx.confirmHandler }, "确认", 8 /* PROPS */, ["onClick"]),
_createVNode("span", null, _toDisplayString(_ctx.vue3), 1 /* TEXT */)
]))
}
基于Proxy的响应式对象
Proxy API对应的Proxy对象是ES2015就已引入的一个原生对象,用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)。
从字面意思来理解,Proxy对象是目标对象的一个代理器,任何对目标对象的操作(实例化,添加/删除/修改属性等等),都必须通过该代理器。因此我们可以把来自外界的所有操作进行拦截和过滤或者修改等操作。例如下面的示例:
let foo = {
a:1,
b:2
}
let handler = {
set:(obj,key,value)=>{
console.log('set')
obj[key] = value
return true
}
}
let p = new Proxy(foo,handler)
p.a = 3 // 打印出console.log('set')
在Vue2.x中,使用Object.defineProperty()来实现响应式对象,对于一些复杂的对象,还需要循环递归的给每个属性增加上getter/setter监听器,这使得组件的初始化非常耗时,而Vue3中,composition-api提供了一种创建响应式对象的方法reactive,其内部就是利用了Proxy API来实现的,这样就可以不用针对每个属性来一一进行添加,减少开销提升性能。更多关于Proxy和响应式对象可以参考这篇文章。
更小
Tree shaking支持
Tree shaking是一个术语,通常用于描述移除javascript上下文中的未引用代码(dead-code),就像一棵大树,将那些无用的叶子都摇掉。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import 和 export。这个术语和概念在打包工具rollup和wepack中普及开来。
import {get} from './api.js'
let doSome = ()=>{
get()
}
doSome()
api.js代码:
let post = ()=>{
console.log('post')
}
export post
let get = ()=>{
console.log('get')
}
export get
上面代码中,api.js代码中的post方法相关内容是没有被引入和使用的,有了Tree shaking之后,这部分内容是不会被打包的,这就在一定程度上减少了资源的大小。使用Tree shaking的原理是ES6的模块静态分析引入,这就可以在编译时正确判断到底加载了什么代码,但是要注意import 和 export是ES6原生的而不是通过babel或者webpack转化的。
在Vue3中,对代码结构进行了优化,让其更加符合Tree shaking的结构,这样使用相关的api时,不会把所有的都打包进来,只会打包你用到的api,例如:
<!-- vue 2.x -->
import Vue from 'vue'
new Vue()
Vue.nextTick(() => {})
const obj = Vue.observable({})
<!-- vue 3.x -->
import { nextTick, observable,createApp } from 'vue'
nextTick(() => {})
const obj = observable({})
createApp({})
同时,例如 <keep-alive>
和 <transition>
, <teleport>
等内置组件,如果没有使用也不会被打包到资源里。
更易于维护
Vue3从Flow迁移到TypeScript
在Vue3的源码结构层面,从Flow改成了TypeScript来编写,一般来说对于JavaScript源码框架来说引入类型检测是非常重要的,不仅可以减少bug的产生,还可以规范一些接口的定义,Flowfacebook 出品,是一个静态类型检测器,有了它就可以在JavaScript运行前找出常见的 bug,包括:
自动类型转换
null 引用
可怕的 undefined is not a function
例如:
// @flow
function foo(x: number): number {
return x + 10
}
foo('hi') // 参数x需要为number类型,否则就会报错
message: '[flow] string (This type is incompatible with number See also: function call)'
这些特性和typescript非常吻合,所以在Vue3中直接采用了typescript来进行重写,从源码层面来提升项目的可维护性。
代码目录结构遵循monorepo
monorepo是一种管理代码的方式,它的核心观点是所有的项目在一个代码仓库中,但是代码分割到一个个小的模块中,而不是都放在src这个目录下面。这样的分割,每个开发者大部分时只是工作在少数的几个文件夹以内的,并且也只会编译自己负责的模块,而且不会导致一个 IDE 打不开太大的项目之类的事情,这样很多事情就简单了很多。如下图:
目前诸如 Babel, React, Angular, Ember, Meteor, Jest 等等都采用了 Monorepo 这种方式来进行源码的管理。
Vue2.x的目录结构:
Vue3的目录结构:
新功能和特性
Composition API
在Vue2.x中,组件的主要逻辑是通过一些配置项来编写,包括一些内置的生命周期方法或者组件方法,例如下面的代码:
export default {
name: 'test',
components: {},
props: {},
data () {
return {}
},
created(){},
mounted () {},
watch:{},
methods: {}
}
这中基于配置的组件写法成为Options API(配置式API),Vue3的一大核心特性是引入了Composition API(组合式API),这使得组件的大部分内容都可以通过setup()方法进行配置,同时Composition API在Vue2.x也可以使用,需要通过安装@vue/composition-api来使用,代码如下:
npm install @vue/composition-api
...
import VueCompositionApi from '@vue/composition-api';
Vue.use(VueCompositionApi);
下面就列举一些使用Composition API的例子:
ref或者reactive代替data中的变量:
在Vue2.x中通过组件data的方法来定义一些当前组件的数据:
...
data() {
return {
name: 'test',
list: [],
}
},
...
在Vue3中通过ref或者reactive创建响应式对象:
import {ref,reactive} from 'vue'
...
setup(){
const name = ref('test')
const state = reactive({
list: []
})
return {
name,
state
}
}
...
ref将给定的值创建一个响应式的数据对象并赋值初始值(int或者string),reactive可以直接定义复杂响应式对象。
methods中定义的方法也可以写在setup()中:
在Vue2.x中methods来定义一些当前组件内部方法:
...
methods: {
fetchData() {
},
}
...
在Vue3中直接在setup方法中定义并return:
...
setup(){
const fetchData = ()=>{
console.log('fetchData')
}
return {
fetchData
}
}
...
setup()中使用props和this:
在Vue2.x中,组件的方法中可以通过this获取到当前组件的实例,并执行data变量的修改,方法的调用,组件的通信等等,但是在Vue3中,setup()在beforeCreate和created时机就已调用,无法使用和Vue2.x一样的this,但是可以通过接收setup(props,ctx)的方法,获取到当前组件的实例和props:
export default {
props: {
name: String,
},
setup(props,ctx) {
console.log(props.name)
ctx.emit('event')
},
}
注意ctx和2.x中this并不完全一样,而是选择性地暴露了一些property,主要有[attrs,emit,slots]。
watch来监听对象改变
Vue2.x中,可以采用watch来监听一个对象属性是否有改动:
...
data(){
return {
name: 'a'
}
},
watch: {
name(val) {
console.log(val)
}
}
...
Vue3中,在setup()中,可以使用watch来监听:
...
import {watch} from 'vue'
setup(){
let state = reactive({
name: 'a'
})
watch(
() => state.name,
(val, oldVal) => {
console.log(val)
}
)
state.name = 'b'
return {
state
}
}
...
在Vue3中,如果watch的是一个数组array对象,那么如果调用array.push()方法添加一条数据,并不会触发watch方法,必须重新给array赋值:
let state = reactive({
list: []
})
watch(
() => state.list,
(val, oldVal) => {
console.log(val)
}
)
state.list.push(1) // 不会触发watch
state.list = [1] // 会触发watch
此问题不知是否是Vue3.x特意加上的,有待正式版出来后在验证。
computed计算属性:
Vue2.x中:
...
computed: {
storeData () {
return this.$store.state.storeData
},
},
...
Vue3中:
...
import {computed} from 'vue'
setup(){
const storeData = computed(() => store.state.storeData)
return {
storeData
}
}
...
当然,对于完整的Vue Composition API,各位同学可以参考文档
Fragments
在Vue2.x中, <template>
中的内容必须由一个最外层的父元素包裹,代码如下:
<template>
<div>
<header>...</header>
<main>...</main>
<footer>...</footer>
</div>
</template>
结果发现React社区也遇到了同样的问题。他们想出的解决方案是一个名为Fragment的虚拟元素,使用方法如下:
class Hello extends React.Component {
render() {
return (
<React.Fragment>
<header>...</header>
<main>...</main>
<footer>...</footer>
</React.Fragment>
);
}
}
而在Vue3中,使用更加简单,可以直接省略最外层的元素,写法如下:
<template>
<header>...</header>
<main>...</main>
<footer>...</footer>
</template>
在Vue3中, <teleport>
是一个内置标签,如果你曾经创建过模态功能,你会知道它通常被放置在关闭的 </body>
标签之前,如下:
<body>
<div>
<!--main page content here-->
</div>
<!--modal here-->
</body>
如果按照以往的思路,需要将模态的UI代码放在底部,如下:
<body>
<div id="app">
<h3>Tooltips with Vue 3 Teleport</h3>
</div>
<div>
<modal-button></modal-button>
</div>
</body>
这样做是因为模式通常具有覆盖页面的背景,要使用CSS来实现,您不需要处理父元素定位和z-index堆栈上下文,因此最简单的解决方案是将模式放在页面的最底部。这样的话这部分逻辑就脱离了整个项目的跟组件App的管理,就造成直接采用JavaScript和CSS来修改UI,并不是很规范。为了允许将一些UI片段段移动到页面中的其他位置,在Vue3中添加了一个新的 <teleport>
组件。
要使用 <teleport>
,首先要在页面上添加一个元素,我们要将模态内容移动到该页面。代码如下:
app.component('modal-button', {
template: `
<button @click="modalOpen = true">
Open full screen modal! (With teleport!)
</button>
<teleport to="#endofbody">
<div v-if="modalOpen" class="modal">
...
</div>
</teleport>
`,
data() {
return {
modalOpen: false
}
}
})
<body>
<div id="app">
<h3>Tooltips with Vue 3 Teleport</h3>
</div>
<div id="endofbody"></div>
</body>
其#endofbody表示此部分内容将会添加到页面底部。
<Suspense>
是一个特殊的组件,它将呈现回退内容,而不是对于的组件,直到满足条件为止,这种情况通常是组件setup功能中发生的异步操作或者是异步组件中使用。例如这里有一个场景,父组件展示的内容包含异步的子组件,异步的子组件需要一定的时间才可以加载并展示,这时就需要一个组件处理一些占位逻辑或者加载异常逻辑,要用到 <Suspense>
,例如:
<Suspense>
<template >
<Suspended-component />
</template>
<template #fallback>
Loading...
</template>
</Suspense>
上面代码中,假设 <Suspended-component>
是一个异步组件,直到它完全加载并渲染前都会显示占位内容:Loading,这就是 <Suspense>
的简单用法,该特性和Fragment以及 <teleport>
一样,灵感来自React。
Vite
伴随着Vue3,Vue团队也推出了自己的开发构建工具Vite,可以在一定程度上取代vue-cli和webpack-dev-server的功能,基于此Vite主要有以下特性:
快速的冷启动
即时的模块热更新
真正的按需编译
Vite在开发环境下基于浏览器原生 ES Modules 开发,在生产环境下基于 Rollup 打包,我们先来了解一下ES Modules。
ES Modules
ES Modules简称ESM,是 JavaScript 官方的标准化模块系统。了解过seaJS和requireJS(以及后期的webpack和babel)的都应该知道什么是“模块化”,在使用这两个库是,会用到require()方法去加载模块,使用define()方法去定义模块,这些方法都是库提供给我们的,而浏览器原生是无法识别的。而ESM是ES6给我们提供的标准API,在浏览器支持的情况下,我们可以使用import()以及export()来实现“模块化”,使用方法如下:
导出模块(a.js):
export const name = 'tenny';
export function get(num) {
return {
num: num+10
};
}
导入模块(b.js):
import { name, get } from '/a.js';
import * as aModule from '/a.js';
在HTML中引入使用了ESM的js文件需要在 <script>
标签上添加type="module",例如:
<script type="module" src="b.js"></script>
以上就是简单的ES Modules用法,Vite基于这种写法更符合Tree Shaking的结构规范,同时添加了自己的dev-server和HMR(热更新)机制,这样就可以满足开发阶段的功能提供。
Rollup
Rollup 是一个 JavaScript 模块打包器,也是最先提出Tree Shaking概念,和Webpack或者Browserify有着同样的模块打包功能,它的最大特点是基于ES Modules进行打包,不需要通过类似Babel转化的方案将import转化成Commonjs的require方式,极大地利用浏览器的原生特性。
基于Tree Shaking特性,Rollup可以最大化它保持打包后的文件体积更小,这也是Vite在生产环境下采用Rollup的主要原因。
创建一个Vite app
$ npm init vite-app <project-name>
$ cd <project-name>
$ npm install
$ npm run dev
不只限于Vue,Vite同时提供了创建React app的能力,可以通过配置模板来生成:
npm init vite-app --template react or --template preact
当然,Vite在实际运用中可能不单单是简单创建,期间还有一些配置项,例如Vite+typescript,这里我提供几个创建好的模板项目来供参考:
vue3+typescript+vue-class-component+vite
vue3+typescript+vue-class-component+vuecli
Vue2.x升级指南
对于现有的Vue2.x项目,如果想要平滑的升级到Vue3,在不使用一些新特性的情况下,改动还不算很大,需要关注以下问题。
Vue3中移除的一些API和方法
取消KeyboardEvent.keyCode
在Vue2.x中,绑定键盘事件会用到如下代码:
<!-- keyCode version -->
<input v-on:keyup.13="submit" />
<!-- alias version -->
<input v-on:keyup.enter="submit" />
或者是:
Vue.config.keyCodes = {
f1: 112
}
<!-- keyCode version -->
<input v-on:keyup.112="showHelpText" />
<!-- custom alias version -->
<input v-on:keyup.f1="showHelpText" />
在事件中,给keyup配置一个指定按钮的keyCode(数字)在Vue3中将不会生效,但是依然可以使用别名,例如:
<input v-on:keyup.delete="confirmDelete" />
移除 $on,$off
和 $once
方法
在Vue2.x中可以通过EventBus的方法来实现组件通信:
var EventBus = new Vue()
Vue.prototype.$EventBus = EventBus
...
this.$EventBus.$on() this.$EventBus.$emit()
这种用法在Vue3中就不行了,在Vue3中移除了 $on,$off
等方法(参考rfc),而是推荐使用mitt方案来代替:
import mitt from 'mitt'
const emitter = mitt()
// listen to an event
emitter.on('foo', e => console.log('foo', e) )
// fire an event
emitter.emit('foo', { a: 'b' })
移除filters
在Vue3中,移除了组件的filters项,可以使用methods的或者computed来进行替代:
<template>
<p>{{ accountBalance | currencyUSD }}</p>
</template>
<script>
export default {
filters: {
currencyUSD(value) {
return '$' + value
}
}
}
</script>
替换为:
<template>
<p>{{ accountInUSD }}</p>
</template>
<script>
export default {
props: {
accountBalance: {
type: Number,
required: true
}
},
computed: {
accountInUSD() {
return '$' + this.accountBalance
}
}
}
</script>
移除inline-template
在Vue2.x中,在父组件引入子组件时,会用到inline-template来使子组件的内容也得到展示,参考这里,例如:
<my-component inline-template>
<div>
<p>These are compiled as the component's own template.</p>
<p>Not parent's transclusion content.</p>
</div>
</my-component>
在Vue3中,这个功能将被移除,目前inline-template使用的并不多,这里就不再过多讲解。
Vue3中改变的API和写法
根实例初始化:
在2.x中通过new Vue()的方法来初始化:
import App from './App.vue'
new Vue({
store,
render: h => h(App)
}).$mount('#app')
在3.x中Vue不再是一个构造函数,通过createApp方法初始化:
import App from './App.vue'
createApp(App).use(store).mount('#app')
全局API调用方式改变
在Vue2.x中,大部分全局API都是通过Vue.xxx或者Vue.abc()方式调用,例如:
import Vue from 'vue'
Vue.mixin()
Vue.use()
...
而在Vue3中,这些方式将会改变,取而代之的是如下:
import { createApp } from 'vue'
const app = createApp({})
app.mixin()
app.use()
...
同时,可以只引入一些需要的API,不需要的不用引入,这样也符合Three Shaking的要求,例如:
import { nextTick,reactive,onMounted } from 'vue'
nextTick(() => {
})
onMounted(() => {
})
由于Vue3中全局API都会通过app.xxx的方法调用,所以之前通过Vue.prototype.xxx绑定的全局方法和变量将无法使用,可以采用如下方式来代替:
//在main.js中:
app.config.globalProperties.http = function(){}
//在vue组件中:
this.http()
render方法修改
在Vue2.x中,有时会自定义render方法来返回模板内容,如下:
export default {
render(h) {
return h('div')
}
}
在Vue3中,h通过vue来引入,如下:
import { h } from 'vue'
export default {
render() {
return h('div')
}
}
新的异步组件创建方式
在Vue2.x中,尤其是在Vue Router中,会经常使用到异步组件,借助webpack的打包方式,可以将一个组件的代码进行异步获取,例如:
const asyncPage = () => import('./NextPage.vue')
const asyncPage = {
component: () => import('./NextPage.vue'),
delay: 200,
timeout: 3000,
error: ErrorComponent,
loading: LoadingComponent
}
在Vue3中,提供了defineAsyncComponent()方法创建异步组件,同时可以返回一个Promise对象来自己控制加载完成时机,如下:
import { defineAsyncComponent } from 'vue'
const asyncPageWithOptions = defineAsyncComponent({
loader: () => import('./NextPage.vue'),
delay: 200,
timeout: 3000,
error: ErrorComponent,
loading: LoadingComponent
})
const asyncComponent = defineAsyncComponent(
() =>
new Promise((resolve, reject) => {
/* ... */
})
)
data属性只支持function
在Vue2.x中,根组件的data可以直接配置一个对象,子组件的data可以配置function,然后返回一个对象,如下:
<!-- Object Declaration -->
<script>
const app = new Vue({
data: {
apiKey: 'a1b2c3'
}
})
</script>
<!-- Function Declaration -->
<script>
const child = new Vue({
data() {
return {
apiKey: 'a1b2c3'
}
}
})
</script>
在Vue3中,所有组件都只支持配置function返回一个对象的方式,如下:
<script>
import { createApp } from 'vue'
createApp({
data() {
return {
apiKey: 'a1b2c3'
}
}
}).mount('#app')
</script>
使用自定义标签和is属性改动
在Vue2.x中,可以使用一些非HTML标准的标签(例如使用Web Components),需要在Vue全局配置中声明忽略该标签,例如:
<plastic-button></plastic-button>
Vue.config.ignoredElements = ['plastic-button']
在Vue3中,同样支持自定义标签,声明时可以使用如下:
const app = Vue.createApp({})
app.config.isCustomElement = tag => tag === 'plastic-button'
除了自定义标签的修改,在Vue2.x中,会用到动态组件is,例如:
<button is="plastic-button">Click Me!</button>
由于is的特性,这种写法在Vue2.x最终会被渲染成 <plastic-button>
组件,但是在Vue3中,只会把is当作一个普通的props属性,如果想实现Vue2.x一样的效果,可以使用v-is,例如:
需要注意的是在特殊元素 <component>
上配置的is属性用法依然生效。
this.$scopedSlots
替代为 this.$slots
在Vue2.x的某些场景,特别是用到自定义render方法和插槽时,会用到this.$scopedSlots获取数据,例如:
h(LayoutComponent, [
h('div', { slot: 'header' }, this.header),
h('div', { slot: 'content' }, this.content)
])
this.$scopedSlots.header
在Vue3中, this.$scopedSlots
将会被移除,统一替代成 this.$slots
,例如:
h(LayoutComponent, {}, {
header: () => h('div', this.header),
content: () => h('div', this.content)
})
this.$slots.header
自定义指令生命周期方法修改
在Vue2.x中,有时会自己创建自定义指令,例如:
<p v-highlight="yellow">Highlight this text bright yellow</p>
在自定义指令中,可以使用Vue提供的一些声明周期方法,完成具体的逻辑,例如:
Vue.directive('highlight', {
bind(el, binding, vnode) {
el.style.background = binding.value
},
inserted(),
beforeUpdate(),
update(),
componentUpdated(),
beforeUnmount(),
unbind()
})
在Vue3中,主要对这些生命周期方法进行了调整,如下:
Vue.directive('highlight', {
beforeMount(el, binding, vnode) { // 对应bind
el.style.background = binding.value
},
mounted() {}, // 对应inserted
beforeUpdate() {}, // 新增
updated() {}, // 对应update
beforeUnmount() {}, // 新增
unmounted() {} // 对应unbind
})
watch方法不再支持"点分隔"写法
在Vue2.x中,使用watch方法监听对象改变时,如果对象层级较深,可以采用“点分割”的写法,例如:
var vm = new Vue({
data: {
e: {
f: {
g: 5
}
}
},
watch: {
// watch vm.e.f's value: {g: 5}
'e.f': function (val, oldVal) { /* ... */ }
}
})
这种写法在Vue3中将不在支持,包括使用实例方法 this.$watch
。
IE兼容
基于上文中提到的ES6的Proxy特性,对于IE11兼容性并不友好,好在Vue团队会提供兼容IE11的版本,但是某些新的特性可能就无法使用了。
总结
Vue2还会提供一个稳定的2.7版本,并将会长期维护,所以还是建议,如果你的项目很稳定,且对新功能无过多的要求或者迁移成本过高,则不建议升级Vue3。
@吕小鸣曾分享过
Vue3.0相关分享
欢迎自荐投稿,前端早读课等你来
以上是关于第2039期最全的Vue3.0升级指南的主要内容,如果未能解决你的问题,请参考以下文章
Vue3.0全家桶最全入门指南 - 3.x跟2.x的其他差异 (4/4)