什么是前端工程化
认识
将软件工程的方法和原理运用在前端开发中,目的是实现高效开发,有效协同,质量可控。
前端工程化目的:提升业务效率
狭义的讲,前端工程化是指将开发阶段的代码转变成生产环境的代码的一系列步骤。主要包括构建,分支管理,自动化测试,部署等。
广义的讲,我理解还应该包括开发阶段,其中又包括开发框架搭建,基础工具(请求库、路由库等)选型,视图基础组件库选择等基础工具的选择,这些都确定了之后,剩下的就是编写业务代码。总体来讲,前端开发框架的搭建以及业务代码的编写也是工程化的一部分,也就是说,广义上的前端工程化包括了从开发框架搭建、到业务开发、到测试,再到线上部署的整个链路过程。
发展
早期的前端页面由JSP、PHP等在服务端生成,浏览器只负责展现,这个时候前端开发重度依赖开发环境,前后端职责纠缠不清,甚至没有前端概念,可维护性差。Ajax出现后,开始前后端分离,前端职责越来越清晰。而HTML5提出以后,前端正式进入SPA时代。
随着前端页面复杂性增加(功能、特效、数据等),前端出现了各种框架或者说工具库来满足快速构建前端应用需求,例如Backbone、AngularJS、React、Vue等,此时进入前端为主的MVC、MV*时代。
随着应用复杂性提高,应用对前端的要求也随之提高:开发、构建、渲染、维护性、扩展性等各方面都对前端提出了很高的要求。而随着Node.js兴起,各种用nodejs编写的前端工具如雨后春笋冒出来,前端面临的很多问题也都有了解决方案。
React、Vue等工具聚焦于解决UI快速构建的问题,webpack聚焦于解决前端应用打包构建的问题,前端框架是包含前端开发各个链路在内的一整套前端开发解决方案,前端工程说的是从开发到部署线上再到后期迭代这一整个过程,而前端工程化说的则是从工程的角度管理前端开发,形成前端开发流程的一整套开发规范,提高前端开发效率。
所以,为什么前端要工程化?为了提高前端开发效率,提高前端应用的可扩展性、可维护性等性能。
内容
- 规范:代码规范、目录结构规范、前后端接口规范、文档规范、commit规范、流程规范等
- 分支管理:不同的开发人员开发不同的功能或组件,按照统一的流程合并到主干
- 组件化开发:这个是最基本的吧
- 模块管理:一方面,团队引用的模块应该是规范;另一方面,必须保证这些模块可以正确的加入到最终编译好的包文件中
- 前端技术或框架规范:
- CSS方案:CSS Modules、CSS in JS
- 视图方案:Angular、React、Vue、…
- 数据管理方案:redux、mobx、hooks、…
- 路由:react-router、…
- Mock:mock.js
- 请求库:fetch、axios、SWR、umi-request、…
- 渲染方式:CSR、SSR、…
- …
- 自动化测试:为了保证和并进主干的代码达到质量标准,必须有测试,而且测试应该是自动化的,可以回归的。
- 构建:主干更新后,自动将代码编译为最终的目标格式,并且准备好各种静态资源
- webpack
- Babel
- 部署:将构建好的代码部署到生产环境
- 复杂场景解决方案:BFF、SFF、GraphQL、Single-SPA…
- 其他:版本管理、发布方式、灰度、监控、运营、埋点、AB测试、SEO、…
前端工程化演进
JS文件打包规范
AMD/CMD
AMD (规范入口)) ```javascript // 定义一个名为fetch的模块 define(‘fetch’, [‘jquery’], function($) { // 模块代码 return $.ajax; });
// 使用模块 require([‘fetch’, ‘lodash’], function (fetch, ) { fetch({}); console.log(‘lodash version:’, .version); }, function (err) { // … });
**CMD **([规范入口](https://github.com/cmdjs/specification/blob/master/draft/module.md))```javascript// 定义一个名为Hello的模块define(function(require, exports, module) {// 模块代码const _ = require('./count.js'); // 导入一个模块module.exports = 'a module' // 导出一个模块});
CommonJS
// 导入一个模块const _ = require('lodash');// 导出一个值// [OK]模块导出的可以是一个值module.exports = 'String';// [OK]也可以用这种简写的方式为module.exports增加属性exports.field1 = 'String';// [不OK]这么写你就慢慢debug吧exports = 'String';
ES Module
// 默认导出export default {name: 'Kobe Bryant',age: 41,};// 命名导出 - 1export const name = 'Kobe Bryant';export const age = 41;// 命名导出 - 1const name = 'Kobe Bryant';const age = 41;export { name, age };
ES Module 与 Commonjs 区别
引用与拷贝
commonjs 与值的拷贝
除了require, exports表达式语法不同,CommonJS标准的另一个特殊点在于它规定模块导出的都是值的拷贝。commonjs 会在引入时直接获取被引入的值,相当于copy。举个以下简单的例子:
// counter.jslet count = 0;const addCount = () => count++;module.exports = {count,addCount,}// index.jslet { count, addCount } = require('./counter.js');console.log('count: ', count); // 0addCount();console.log('count after adding one: ', count); // 0
这里和ES6 Module的行为是不一致的(具体的差别和原因会在下面逐渐展开)。可以看到,index.js引入的只是count的copy,而非reference,所以当原始的count发生改变时,index.js引入的count并不会感知的到。如果想要在CommonJS规范下感知到count的变化,需要在counter.js中定义一个getter函数,通过闭包的形式获取到最新的值。
ES Module 与值的引用
和CommonJS规范不同,ES Module规范定义一个模块导出的是值的引用。esmodule会在引入时创建一个句柄,在实际调用时才去拿值,相当于soft-link。依然以上面的例子作为说明,如果是ES6 Module规范,结果是这样的:
// counter.jslet count = 0;const addCount = () => count++;export {count,addCount,}// index.jsimport { count, addCount } from './counter.js';console.log('count: ', count); // 0addCount();console.log('count after adding one: ', count); // 1, hint: CommonJS中此值是0
循环加载
如果 A 模块的执行依赖 B 模块,而 B 模块的执行依赖 A 模块,就形成了一个循环加载,结果程序不能工作,或者死机。然而,这样的关系很难避免,因为开发者众多,谁都会在开发自己的模块时使用别人的几个模块。
- ESmodule 采用调用才取值的方式,所以天生不会有问题
Commonjs 会编译生成每个模块的信息,存在内存中,如下结构,这样相同id的资源就不会重复加载了。
{id: '...', //表示属性的模块名exports: {...}; //模块输出的各个接口loaded: true, //表示是否加载完毕//...内容很多,不一一列举了}
打包工具
seajs和browserify
seajs,很早一批的代码管理工具,其功能也很简单,就是一套基于CMD规范的代码引入工具。做一些简单的配置就可以引入文件了:
<script type="text/javascript" src="../static/seajs_module/seajs/2.2.0/sea.js"></script><script>seajs.config({base:'../static/seajs_module', // 基准文件目录alias: {jquery: '/static/lib/jquery/2.1.4/jquery',}});seajs.use('../static/app/src/demo.js') // 入口模块</script>
browserify 很早一批的代码管理工具,其大多通过命令行使用,在编译时按CMD规范吧代码拼接到一个文件:
$ browserify source-entry.js > output.js
现在看上去,其功能已经不能更简单,似乎自己也能写一个。但放在它们诞生的年代都是划时代的产物。而下面几个工具从能力来讲就强很多了。
grunt
grunt是一个任务执行器,其基本运行配置如下: ```javascript module.exports = function(grunt) {
// Project configuration. grunt.initConfig({ pkg: grunt.file.readJSON(‘package.json’), uglify: {
options: {banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n'},build: {src: 'src/<%= pkg.name %>.js',dest: 'build/<%= pkg.name %>.min.js'}
}, jshint: {
files: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'],options: {globals: {jQuery: true}}
}, watch: {
files: ['<%= jshint.files %>'],tasks: ['jshint']
} });
grunt.registerTask(‘default’, [‘jshint’]);
// Load the plugin that provides the “uglify” task. grunt.loadNpmTasks(‘grunt-contrib-uglify’); grunt.loadNpmTasks(‘grunt-contrib-jshint’); grunt.loadNpmTasks(‘grunt-contrib-watch’);
// Default task(s). grunt.registerTask(‘default’, [‘uglify’, ‘jshint’]);
};
不难看出,grunt运行完全是在写逻辑代码,每调用一个函数(指令)就做一键事,这一点很像是在shell中执行命令。同时,grunt在一定程度上是支持插件能力的,但因其配置过于简单,对复杂的打包需求无法满足。<a name="J160E"></a>### gulpgulp 是一个命令化调用的打包工具,代码风格和grunt完全不同:```javascriptconst gulp = require('gulp');const clean = require('gulp-clean');const ts = require("gulp-typescript");function cleanLib() {return gulp.src('lib', {read: false, allowEmpty: true}).pipe(clean());}function copyFiles() {return gulp.src(['package.json', 'README.md', 'LICENSE']).pipe(gulp.dest('lib/'));}function compileTS() {const tsProject = ts.createProject("tsconfig.json", {module: "esnext"});return tsProject.src().pipe(tsProject()).pipe(gulp.dest("lib/es"));}exports.prebuild = gulp.series(cleanLib, copyFiles)// or// gulp.task('prebuild', gulp.series(cleanLib, copyFiles));
gulp运行你写很多任务函数,然后通过(串行or并行)执行相关的函数,完成整体打包。同时gulp提供的pipe调用可以很方便的抽离公共能力,形成工具方法。但gulp的灵活性换来却是学习成本升高和打包性能参差不齐,如果你不了解glup的各种api,想搞定复杂的打包也会吃力很多。
webpack
webpack应该是现在使用最广泛的打包工具了,比起上述打包工具,纯配置化的书写上手时候回简单很多,而且最强大的在于webpack几乎可以完成所有打包需要及优化需要,这是它得以被认可的主要原因。基本配置如下:
// webpack.config.jsconst path = require('path');module.exports = {entry: './path/to/my/entry/file.js',output: {path: path.resolve(__dirname, 'dist'),filename: 'my-first-webpack.bundle.js',},};
rollup
rollup的突出特点是简单的可配置化,看上去比webpack清晰很多
export default [{input: 'main-a.js',output: {file: 'dist/bundle-a.js',format: 'cjs'}}, {input: 'main-b.js',output: [{file: 'dist/bundle-b1.js',format: 'cjs'},{file: 'dist/bundle-b2.js',format: 'es'}]}];
虽然对于复杂的项目打包场景webpack依然是首选,但在发布包的过程中,rollup的简单性就十分突出,即便它打包结果多数情况下会比webpack大一些,但完全不影响它极佳的开发体验和低廉的上手成本。
esbuild
通过Go预发开发的新一代构建共建,性能极高,适合CPU密集性的操作。
通常用来做编译、代码压缩等操作。但如webpack擅长的IO操作通常不会交给esbuild处理。因此esbuild通常和webpack或vite一起使用。
Vite
主打下一代打包工具,配置上手很快,而且多数配置和webpack很相似,插件开发成本也比webpack更低:
import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'export default defineConfig({base: '/src',build: {lib: {name,entry: path.resolve(__dirname, 'src/index.tsx'),formats: ['umd'],},},plugins: [react()],css: {modules: {localsConvention: 'camelCaseOnly', // 我们使用驼峰形式},preprocessorOptions: {less: {javascriptEnabled: true,},},},resolve: {alias: [{find: /^lodash$/,replacement: 'lodash-es',}],},})
优势:
1. 面向未来:项目打包结果不再编译到es5代码,直接采用 script module加载代码,开发几乎不用等待;
2. 基于esbuild实现代码压缩,远程构建一样飞快;
3. 主流框架开箱即用;
劣势:
生态没有webpack丰富
扩展
前端发展到今天,已经沉淀了许多工程化经验和工具,这里仅列举了一些有代表性的成果,作为了解即可。
常见框架/工具
| 三大框架 | vue-cli、create-react-app、@angular/cli | |
|---|---|---|
| 包管理工具 | npm、yarn、tnpm、pnpm | |
| 小程序 | uniapp、Taro、omi | |
| 跨端 | 移动端 | rax/weex、react-native、angular-native |
| 桌面端 | NW.js、electron | |
| 其他 | PWA、Flutter | |
| 服务端 | Express、koa、Egg、Midway | |
| 其他 | 微前端 | single-spa、qiankun、iceStark、 |
| mono | lerna | |
| 新标准 | webAssembly |
新一代js服务端开发环境 Deno
| Node | Deno |
|---|---|
| 接口不友好、逐步偏离ES标准 | 严格符合ES标准 |
| 虚假包,恶意包无法控制 | 完整的安全权限控制 |
| 包管理太复杂,安全问题突出 | 没有包概念,cdn直接引入 |
| 自带npm不支持monoRepo,需切换yarn | |
| 配置scripts,开发繁琐 | 自带完整项目开发生命周期 |
| 原生只支持javascript开发 | 原生支持Typescript |
