jQuery到Vue的迁移之路

阿凡达2018-08-01 12:45

背景

在前段时间做了L10的某个超复杂超多坑的三端专题之后,组里的小伙伴们一致认为是时候想办法统一一下组里的开发模式了。因为用nie那一套jQuery/zepto(下文jQuery默认包括zepto)的话,十个人就有十种习惯,不管是代码组织也好,页面结构也好,逻辑处理也好……

其实如果像一般的专题,开发周期短,生命周期短的,用传统的方式开发也还好,不需要后期维护,不需要多人协作。但是,如果项目稍微复杂一点,问题就来了,一碰到需要多人协作的项目,不同的人都有不同的组织代码的习惯,维护起来效率相当低下。所以,我们决定是时候在稍微复杂一点的项目中用一些特定的技术,这样就能通过某些约定好的方式来开发一个项目,尽量降低协作开发和维护的成本。

由于在这之前,我也用过一些mv*的技术去开发一些需要长期维护的项目,例如react、vue。考虑到相比较而言vue的易上手性,我和组内的同事决定用vue的那一套技术,vue用于视图层,vue-router用于单页应用的路由,vuex我觉得可以暂时不用考虑,因为我们现在做的项目多数为不需要长期维护的专题类网站,数据结构也不会复杂到需要用到数据流工具的程度,管理数据可以根据vuex的思想实现一个非常简单的简化版vuex。

Vue V.S. jQuery

讲道理把Vue和jQuery摆在一起比较是不太合适的,一个是mv*架构中的view层的一个库,一个是跟DOM操作结合比较紧密的js库,干的事情不同,也就不存在直接的对立关系。社区里很多人比较这两种技术可能是出于vue所提倡的数据驱动视图和jQuery的直接操作DOM在编写页面时的思路完全不同的考虑。虽然两种思路是完全不同的,但也不能说是不能一起用的,在某些没有办法的情况下(稍后会说到),把jQuery和vue用在一块是完全没问题的,只是说在项目中没有了纯粹的数据驱动视图了而已。

当然把这两种技术用在一起是肯定不会出现在最佳实践里的,因为确实没有特殊情况的话,这样用就是有点自找麻烦了。尴尬的是,上面提到的没有办法的情况出现了,我们部门的组件库里的组件全部是基于jQuery的,其中有纯UI组件和跟业务有强关联的UI组件。其中,纯UI组件很好办,自己用vue写一个或者在vue社区里找一个代替就行(尤其是现在vue的社区资源正在蓬勃发展,有很多已经非常成熟的持续维护的vue组件库),但是跟业务有关的组件就没办法了,这就是为什么会把vue和jQuery一起用的原因。

好在jQuery和vue只是在编程思路上有所不同,两个技术的应用场景并不冲突,只是在开发过程中需要多注意一下,在数据驱动视图的代码中,还混入了一些直接操作DOM的代码。

代码组织构建工具

在说代码组织之前,还得先说说构建工具,毕竟构建跟代码的组织方式息息相关。

你都用vue了,构建工具有啥好说的,不用webpack还有其他更好的?

没错,用vue开发项目,webpack绝对是首选,各种loader帮助你简化你的代码,还有其他各种分开打包的方案、js懒加载等帮助你优化页面的加载速度。

但是,第二个尴尬的情况又出现了,部门内部的所有项目都是基于fis3构建的,也就意味着所有测试机器和正式及其上都是fis3的这套构建系统,你在本地用webpack打包是没法发布的。

考虑到迁移成本,部门短期内是不可能从fis3迁移到webpack了,这也是没办法的事情。因此,要想舒服地使用vue就得想其他办法。如果仔细看过文档的话,其实vue是可以直接引入js文件来进行非构建开发的,需要做的事情就是引入vue的js文件然后用Vue对象初始化应用就行了,非常简单。但是如果没有构建过程的话,就意味着我们在写vue组件的时候需要写难读的字符串模板或者更难读的render函数,而没法写像用webpack时的单文件组件

这个显然是不能忍的。写过或者读过webpack的loader的同学应该都清楚,loader其实就是一个处理字符串的函数,并不是啥黑科技,之所以webpack加了vue-loader之后能使用单文件组件,是因为vue-loader会将输入的.vue文件当成字符串,然后根据<template>,<style><script>标签来将对应的内容编译成对应的文件类型,再交给下游的构建插件处理。vue-loader的工作原理大致如下:

所谓单文件组件就是允许开发者在开发阶段将html模板jscss写在一个文件里,然后配置了vue-loader的webpack会帮你做接下来的一切。简单来说就是拆分 + 注册组件

那么在fis的构建过程中,准确的来说是线上机器的构建流程中,是没有vue-loader的。因为就算我们在本地自己写一个fis3的插件,实现类似vue-loader的字符串编译功能,线上机器也没有装,所以写在一个文件里这个愿望算是落空了。那么退一步来想想,其实写在三个文件里也可以接受,把他们放在一个文件夹里,文件夹以组件名命名,虽然跟单组件相比挫了一点,但是也算是(强行)单文件夹组件了,对代码组织还是有好处的。我们用的fis3中有__inline()功能可以将html作为字符串引入js,这样就可以将模板脱离组件逻辑js文件编写,这样就能像单文件组件那样讲模板和逻辑以及样式分开来写,又在触手可及的地方。

代码组织

虽然不能写单文件组件,但我们可以把单个组件的htmljscss分开写并且放在一个文件夹里。由于目前刚将项目迁移到vue,代码组织方面应该后续还有很多需要优化的地方。目前的代码结构为:

根目录下有一个src文件夹和一个fis-conf.js文件。src文件夹中装项目代码,fis-conf.js即fis3的配置文件,根据项目情况配置即可。下面主要来看看src下其他文件的结构。

components & containers & plugins

之所把这三个部分放一起是因为他们都属于vue组件的范畴,顾名思义,components=>组件,containers=>容器,plugins=>插件。这三个文件夹里的结构都是一样的,下面以component为例:

假如我们有个叫some-component的组件,我们将这个组件的html,js,css文件放在some-component文件夹中。其中,index.html中写组件的模板,index.less中写组件的样式,index.js中写组件的逻辑,最后,我们会在初始化整个应用的地方,即之后会提到的/src/js/app/app.js中注册组件。

components,containers,plugins的区别

在应用中,我将vue的组件分成了三类,分别是组件、容器组件和插件。

容器组件:用于路由初始化的外层组件。有点像react-redux技术栈中跟store链接的那部分组件,不同的是这里并不是按数据层的流向来区分组件,而是通过页面的结构来分的。容器组件仅用于vue-router<router-view>所替换的组件,容器组件中包含的组件都输入普通组件。

组件:除了容器组件的通过html标签调用的其他组件。除了容器组件,其他同过html标签调用的组件都输入普通组件。普通组件又有点像react-redux中的高阶组件。在react中,高阶组件被建议作为pure render(纯渲染)组件使用,也就是只根据父组件(容器组件)传入的props来渲染视图,而没有本身的状态。但是这里同样,暂时不讨论数据层,普通组件只是单纯的作为容器组件的子组件使用。至于组件内部是否需要保留内部的状态,之后再讨论。

插件:需要在js中调用的组件。使用vue或者react这样的数据驱动视图的库,如果只是使用html标签来将组件写到html中,再通过修改数据来更新视图,在大多数情况下是可行的,但是有些场景下也很有局限,比如说需要一个开发一个alert组件。调用alert明显是应该在js中来进行的,因此vue又多了一种叫做插件的组件。

组件(component, container)写法

对于组件,不管是普通的组件还是容器组件,写法都是一样的,只是注册的方式存在差异。 首先,我们可以先写好组件的html模板:

// some-component/index.html
<div class="some-component">
    ...
</div>

之后,我们可以开始写组件的逻辑:

// some-component/index.js
nie.define('someComponent', function() {
    var component = {
        template: __inline('./index/html'),  // 引入刚刚的html模板
        data() {
            return {
                state: $store.state,     // 绑定全局视图状态
                ...                      // 组件内部状态
            }
        },
        created() {
            ...
        },
        ...
    };
    return component;
});

在不动fis配置文件的情况下,没办法使用commonjs或者es6的模块化方案,因此这里直接使用nie的类requirejs的模块化方案来定义我们的vue组件,反正开篇也提到了短期内是甩不掉基于jQuery的nie库了┑( ̄Д  ̄)┍,倒不如利用起来。

之后就是写组件的样式了,没什么好说的,只用注意下没有webpack的loader给组件的class加上命名空间,我们自己需要注意不要有全局的相同的class。我的方法是每个组件的外层都把class写为组件名,组件内部的样式都在这个class的包装下,这样只要组件名不重复,样式也不会有冲突:

// some-component/index.less
.some-component {
    ...
}

插件(plugin)写法

插件的写法稍微复杂一点,在返回的对象中我们需要手动添加一个install方法,在之后的注册过程中,Vue会调用这个方法,并且将Vue对象当做第一个参数传入这个方法:

nie.define('Alert', function() {
    var Alert = {};
    Alert.install = function(Vue, options) {
        Vue.prototype.$alert = {
            show: function() {
            },
            hide: function(callback) {
            }
        };
    };
    return Alert;
});

普通组件(component)注册和使用

在初始化vue应用的app.js中,注册组件:

// /src/js/app/app.js
// 引入组件的js文件
__inline('/src/components/some-component/index.js');

// 加载之前定义的nie组件
var someComponent = nie.require('someComponent');

// 注册全局组件
var components = {
    'some-component': someComponent,
    // ...
};
for (var key in components) {
    Vue.component(key, components[key]);    // 注册全局组件
}

注册之后,就能使用some-component标签在html中调用组件了。

// another-component/index.html
<div class="another-component">
    <some-component></some-component>
</div>

容器组件(container)注册和使用

由于容器组件是替代vue-router的<router-view>元素的组件,因此,容器组件的注册是在vue-router初始化的时候来进行的。同样在app.js中:

// /src/js/app/app.js
// 引入容器组件的js文件
__inline('/src/containers/Main/index.js');

// 加载之前定义的nie组件
var Main = nie.require('Main');

// 路由对象
var router = new VueRouter({
    routes: [
        // ...
        {path: '/main', component: Main},
        // ...
    ]
});

// 初始化vue应用
new Vue({
    el: '#app',
    router: router, // 注册路由,路由组件也就跟着一起注册了
    // ...
})

接下来,在路由视图切换的地方,注册过的路由组件就会根据路由匹配来自动替换<router-view>元素。

插件(plugin)注册和使用

插件的js中,调用了Vue.install()方法,接下来在app.js中:

// /src/js/app/app.js
// 引入插件的js文件
__inline('/src/plugins/Alert/index.js');

// 加载之前定义的nie组件
var Alert = nie.require('Alert');

// 注册插件
var plugins = [
    Alert,
    // ...
];
plugins.forEach(function(plugin) {
    Vue.use(plugin);
});

注册之后,在所有的组件实例对象上面都会多一个$alert对象,其中包含showhide方法,比如在一个对象中:

// some-component/index.js

var component = {
    // ...
    methods: {
        alert() {
            this.$alert.show(); // 调用Alert插件
        }
    }
}

css

css文件夹下会存放一个index.less的应用入口的样式文件,其中引入了各个组件的样式:

// /src/css/index.less
// 容器组件
@import "../containers/Main/index.less";
// 组件
@import "../components/some-component/index.less";
// 插件
@import "../plugins/Alert/index.less";

还可以根据项目需求放一些其他的样式文件。

imgs

imgs文件夹放项目中用到的图片资源。

inline

inline文件夹放需要编辑填写内容的html文件,再通过fis的inline引入到其他的html中,方便编辑找到对应的内容位置。

js

js文件夹结构如下:

其中,app/app.js是我们应用的入口文件,即/src/index.html中直接引入的js,在其中进行了注册组件、注册插件、初始化路由、初始化vue应用等过程。

common文件夹下的js文件每一个都对应一个全局对象,对象中有某些具体的方法可以随时供调用,这个结构可以根据个人的不同习惯来组织,我的结构是:

api.js:存放所有与后台交互的接口。在组件逻辑中通过$api.someApi(params, successCallback, errorCallback)调用。

common.js:工具方法。通过$common.someFunc()调用。

store.js:简版vuex的store对象,存放全局视图状态,也就是前面绑定在组件中的$store.state。数据流部分稍后会提到。

关于数据流

用数据驱动视图的框架,都存在着视图所依赖的那部分数据。由于在应用中,有不同的视图可能会依赖于同一份数据,也有多份数据可能被同一视图依赖。相反,可能会有很多地方会更新同一份数据,也可能在同一个地方更新多份数据。这些数据被称为视图的状态,放在全局的叫做全局视图状态。由于存在上面提到的映射关系,在视图状态复杂到一定程度的时候,维护起来就会是噩梦。为此,就有了下面的数据流工具。

redux

redux当初是使用react开发单页应用时认可度比较高的数据流方案。redux推崇绝对的数据流单向数据不可变(immutable)。react的组件也根据redux衍生出了容器组件高阶组件两种。容器组件用于与redux的store相连,store将state通过props传给容器组件,容器组件内部也有非全局的state。其他位于容器组件内部的组件在最佳实践中,都被认为最好写为高阶组件,也就是纯渲染组件(pure render)。纯渲染组件根据容器组件传入的props来渲染视图,没有内部state。这样加上redux本身的reducer immutable地去改变state的过程,可以提高react组件的渲染性能。

上面一大段话看起来就感觉很复杂,与其说复杂,不如说是繁琐。因为redux为了保证在复杂项目中数据流的可控性与可追踪性,预先规定了非常多的条条框框,在写代码的时候显得比较繁琐,换来的是大型项目中数据层的可维护性。

所以我认为,在项目的数据层不复杂到一定程度,使用redux是不划算的。顺便附上redux作者的一篇文章You Might Not Need Redux

vuex

vuex是vue作者开发的适用于vue的数据流工具。其大致思想跟Redux相近,但在API调用和数据流动方式方面还是有一点区别。例如,vuex中,想要改变state的值需要调用store.dispatch('some action')来调用action,这个actionRedux中的action概念差不多,可以进行异步操作,然后在action中来调用store.commit('some mutation')触发mutationmutationreduxreducer相似,对state直接进行操作,不能做异步操作。(从vuex-v2.0.0开始vuex外部也能调用store.commit()来调用mutation)。

使用vuex的话,可以不用像redux那样,变更和接收变更只能以容器组件为桥梁,而是可以从任何组件dispatch一个action,从而改变全局state,然后依赖全局state的组件的对应视图自动更新。这种架构在没那么复杂的项目中比较好用,我们可以跟踪数据流的变化,也具有一定的可维护性。我个人是觉得不局限于redux的实践写起来更舒服一点,不过等应用复杂到一定程度的时候,数据的可维护性也会降低。因为那时候数据的改变逻辑会分布在各个组件的内部,而不是只有容器组件会触发,维护起来会很麻烦。

所以,如果应用比较复杂的话,我们可以使用vuex实现redux的最佳实践,也就是我之前提到的容器组件和普通组件。容器组件就用来跟vuex的store打交道,而内部的普通组件通过容器组件传入的props做纯渲染组件。这样就能通过vuex实现redux对于复杂项目的最佳实践。

目前项目中使用的数据流工具

说了半天,其实现在在项目中并没有使用任何的数据流工具,原因在文章开头也提到了,就是目前我们做的项目数据层并不复杂,甚至可以说是非常简单。所以我认为,在目前的数据复杂度下,没必要用开发效率去换可维护行,尤其是目前大多数的项目都属于不需要长期维护的专题类型。

所以就有了在之前代码组织一节中提到的一个全局store,在每个组件中都将$store.state绑定到了data上,这样$store.state就成了全局视图状态。在应用的任何地方都可以对$store.state进行更新,更新之后,依赖于该state的其他视图也会相应地更新。目前的方案对于目前的复杂度完全够用,写起来也不会影响效率。

从jQuery迁移到vue的意义

有利于团队协作,多端维护

开篇提到,想往vue迁移的根本原因还是开发复杂项目需要团队协作的时候,如果没有一套约定好的项目架构,那么在多人协作或者是跨端维护的时候就会非常痛苦。

有了vue之后,可以将能复用的组件的模板、样式和逻辑进行封装,供今后使用。因为vue提供了一套组件的生命周期钩子,所以我们在写组件逻辑的时候,怎么都不会跳出生命周期的范畴,就没有了用jQuery时,10个人就有10种写法的问题。

另外,我们还引入了vue-router来用作单页的路由方案。没有这个之前,同样,专题结构参差不齐,有的是自己实现的单页,有的是伪单页,有用第三方路由库的,有的甚至没有做成单页。面对这样的问题,如果只是一个人开发还好,只用按照自己的习惯来就好,但是一旦需要多人协作或者之后维护,问题就来了。所以有了vue-router之后,单页的方案统一了,不同的项目用一套单页方案,不管是团队协作,还是今后的维护,都是一目了然的。

关于数据流工具,上面已经详细分析了,我的意见是,根据跟视图有关联的数据的复杂度,从低到高,简版全局store > vuex > redux

这样,对于项目整体,从mvvm的角度来说,m(数据model)层统一了方案,v(视图view)层统一了方案,前端路由也统一了方案,剩下vm(视图模型viewmodel,也就是组件的状态)层在项目不复杂的时候可以自由发挥,也就是不用太多地去考虑组件内部是否应该维护一份本地的状态,如果一旦项目达到了一定的复杂度,我们可以考虑实践redux推崇的容器组件和纯渲染组件的思想。

因此,我认为通过这样在技术方案上的统一,能够大大降低团队协作和后期维护的成本。

有利于提高开发效率

对于需要渲染后台传过来的数据的项目:

用vue之前,我们从后台拿到数据,然后对数据进行一定程度上的处理,然后把处理后的数据转换成渲染逻辑,手动操作DOM更新

用vue之后,我们从后台拿到数据后,把数据进行处理之后,直接交给数据层,vue会自动对比变更前后的数据,自动更新DOM

所以在开发效率上的提升主要体现在用vue之后,我们不用考虑渲染逻辑,不用手动处理DOM,这部分事情vue帮我们做了,我们只用把心思放在业务逻辑上即可。我认为这也是我们做数据类项目应该有的思路。当然,纯展示类、秀动画类项目除外。

有利于提高渲染性能

项目中经常会有这样的应用场景:从后台拿到一个数组,把这个数组渲染成一个长列表。在用户的交互过程中,用户可以根据输入具体搜索条件进行筛选或者排序。在这种应用场景下,除了上面提到的只用对数据进行操作而不用管渲染逻辑的好处之外,vue还会对列表的每个元素进行缓存并跟新的数据进行比对(如果给v-for中的每个元素设置了key的话),进行差量更新,从而提高渲染性能。

vue的缺点

动画

就算是在偏数据类型的项目中,也可能会存在强动画需求,尤其是游戏部门的项目。那么相比而言,用vue来写动画就没有用jQuery那么直观了。究其原因,我认为动画的思路跟业务是相反的。业务的核心是数据,因此我们的编写业务代码的时候,中心应该放在数据的处理上,剩下的比如说更新DOM就交给框架来好了,但是在编写动画的时候,动画的主体是DOM,因此如果我们要通过改变数据再去控制动画就显得有些怪了。

vue动画:

jQuery动画:

但是虽然思路跟以前是不太一样,但是jQuery能实现的动画vue都可以实现,因此也不算是个大问题,只是在写动画的时候可能会比jQuery直接操作DOM要多走几步路而已。

还有待提高的地方

组件js文件引入方式

在之前代码组织部分详细说过的组件的编写方式里,需要将组件的js文件用fis3的__inline()方法引入到入口文件app.js中,然后再通过nie.require()方法加载组件对象,最后再用该对象来注册组件。整个过程用下来感觉稍繁琐,因为这是在没法用ES6的module模块的情况下的妥协方案。组内同时研究发现现在fis3的babel插件再跟其他插件组合工作的时候还存在冲突,等这个冲突解决之后,用上ES6的模块化方案之后,代码会更加简洁。

使用vue + jQuery需要注意的地方

注意两种技术更新DOM的方式

使用jQuery,我们是直接操作DOM,DOM的更新是同步的。使用vue,想更新DOM,我们直接操作的是数据,由于vue会对比新旧数据避免频繁更新,vue会将更新DOM的操作放在下一个事件循环(event loop)里,也就是说DOM的更新是异步的。

前面说了,我们现在有个非常尴尬的状况就是不得不同时使用两种技术,因此,在开发的时候需要注意两种DOM更新的方式,避免该取DOM的时候DOM还没有更新的情况。

注意vue组件生命周期 + vue-router路由钩子

vue组件的生命周期是针对组件实例在从初始化到销毁的整个过程而言的,跟DOM没有直接关系,因此,跟jQuery一起用,有时候会需要访问DOM的时候要尤其小心,有可能在某些生命周期钩子中DOM还没有被渲染出来。

vue-router也有路由钩子函数,在使用的时候也需要注意在相应钩子中能不能获取到DOM的问题,尤其是容器组件带过渡的情况。

Reference

  1. Vue
  2. vue-router
  3. fis3
  4. You Might Not Need Redux
  5. vuex
  6. vux
  7. Element

本文来自网易实践者社区,经作者查马纠西授权发布。