查看原文
其他

【第1272期】从零开始搭建脚手架

unravel 前端早读课 2019-06-17

前言

今日早读文章由欢聚时代@unravel投稿分享。

正文从这开始~

组内已经有了非常完善以及流畅的开发,发布流程,平时只需要默默地搬属于自己的那块砖就好了,但是每当社区出了新的技术,想尝试的时候总是欠缺一个“起手式”,可以快速将新的技术给集成到自己的脚手架,或者说工作流中,基于这个目的,想到就开始做了

脚手架对团队的好处不言而喻,可以通过命令行的方式去快速生成种子文件,开发以及输出构建后的代码,平时我们只需要开发,而不用跟复杂的编译过程,搭建服务等流程打交道,另外,还可以将我们需要的node模块安装到脚手架内,以后我们只负责开发而不需要安装庞大的node_module了,保持目录的干净,甚至脚手架还可以跟后续的持续集成相结合,提供更强大的功能

从零开始搭建脚手架需要一定的前端工程化知识,推荐看webpack指引,里面涉及了大量前端工程化需要做的事情,事实上我也是从这里一步一步地往上搭上去的,并最终开发完脚手架qd-cli(音译:前端-cli,语文不好- -!),开发脚手架本质上还是写webpack,用webpack搭建工作流,并最终可以使用commander将其封装成命令行工具。

本文从以下三个方面做介绍,搭建:如何一步步开发qd-cli(包含了我对前端工程化的了解)qd-cli的安装,使用,特性搭建过程中遇到的一些坑

搭建

脚手架技术方案选择

先从简单地做起,再慢慢地往上堆砌,因此,目前考虑的是只支持移动端项目,以及vue技术栈

技术方案:工作流的编写毫无悬念地选择了webpack:https://webpack.js.org/,现下最热门的前端打包工具,webpack首要解决了前端模块化的难题,开箱即用,原生支持`es module,这里选择最新的webpack4,另一方面,将工作流集成成cli`使用commander:https://www.npmjs.com/package/commander

开发环境如何搭建

主要考虑以下三个方面:

本地服务器


在开发环境需要有服务器去启动并自动刷新我们的应用,有时甚至期望可以设置代理,便于前后端联调,可以使用webpack-dev-server,配置很简单

// webpack.config.js
module
.exports = {
 
// ...
+ devServer: {
+   ...
+   contentBase: cwd('dist'),
+   proxy: { ... }
+ }
}

支持热重载


在更改代码后无需手动刷新浏览器即可预览效果,快速便捷,即使js的热重载有点坑,有时需要手动去刷新,但总体还是利大于弊的

const webpack = require('webpack');
module
.exports = {
 devServer
: {
   
...
+   hot: true,
   contentBase
: cwd('dist'),
   proxy
: { ... }
 
},
 plugins
: [
+   new webpack.NamedModulesPlugin(),
+   new webpack.HotModuleReplacementPlugin()
 
]
}

提供sourcemap


webpack打包后的代码报错后不利于我们去定位错误位置,soucemap可以帮我们准确定位到源码的出错位置

const webpack = require('webpack');
module.exports = {
+ devtool: 'inline-source-map'
 devServer
: {
   hot
: true,
   contentBase
: cwd('dist'),
   proxy
: { ... }
 
},
 plugins
: [
   
new webpack.NamedModulesPlugin(),
   
new webpack.HotModuleReplacementPlugin()
 
]
}

更多sourcemap选项:https://webpack.js.org/configuration/devtool/

生产环境配一个最简单的source-map就可以了,因为复杂一点的source-map一般体积都很大

生成环境包太大了怎么办,缓存问题怎么处理😰

生成环境需要尽可能地优化代码的体积,webpack为我们提供了完整的方案,只需一点点的配置

代码分割

代码分割是一件很有必要的操作,在多页应用中,A,B,C页面可能同时依赖了大量的第三方库,将公共库抽取出来利于浏览器做缓存,并能有效减少A,B,C页面的体积

单页应用也应做代码分割,将第三方库抽取出来,一方面,我们平时需要不断迭代的部分一般都是业务代码,第三方库的代码是不会有变动的,这样的抽取同样利于浏览器做缓存,另一方面,js是单线程的,包的体积太大意味着下载变慢,导致js线程被挂起

module.exports = {
 
...
 optimization
: {
   splitChunks
: {
     cacheGroups
: {
       
// 抽取node_modules中的第三方库
       vendors
: {
         test
: /[\\/]node_modules[\\/]/,
         name
: "vendors",
         chunks
: "all"
       
},
       commons
: {
           name
: "commons",
           chunks
: "initial",
           minChunks
: 2
       
}
     
}
   
}
 
}
}

tree shaking

tree shaking利用了export,import的静态特性,将代码中的无用代码给删掉,比如在代码中:

import { forEach } from 'lodash-es'

在最后的打包过程,webpack只会将lodash-es中的forEach方法打包进来,其他无用的代码不会打包进来,摇树(tree shaking)在webpack中的配置非常简单,如下:

module.exports = {
 mode
: 'production'
}

补充:摇树的概念大概指的是,将我们的代码比喻成一棵树,将无用的代码(枯黄的叶子)给摇下来,这里踩了一个坑,后面补充

懒加载

为了提升首屏时间,很多代码都可以延迟加载,在webpack体系打包的代码中,使用懒加载非常方便

// 方法1
import('./someLazyloadCode').then(_ => {...})
// 方法2, 以下使用方式称为魔法注释,可以将最后生成的文件命名为lazyload,利于我们去分析打包后的代码
import(/* webpackChunkName: "lazyload" */ './someLazyloadCode').then(_ => {...})

vue中使用也很方便,可以参考Lazy Loading in Vue using Webpack’s Code Splitting

注意,使用懒加载需要添加promise垫片,因为即使是移动端,某些老版本的浏览器依然不支持promise,可以使用es6-promise或者promise-polyfill

在babel配置里面需要:

module.exports = {
 presets
: [
   
[
     
'env',
     
// 启动懒加载
     
{
       modules
: false
     
}
   
],
   
'stage-2'
 
]
 
...
}

在对webpack作者Tobias的采访中,当被问及能否推荐几个webpack最佳实践?作者如是回答:使用按需加载。非常简单,效果非常好。

打哈希戳

浏览器是有缓存的,代码更改后,如何让浏览器重新加载资源?

传统的做法是在所有资源链接的后面加时间戳,但这样做的坏处是只要更新一个文件,其他没有更改的文件也会因为时间戳的更新而被重新加载,不利于浏览器做缓存,现在业界比较成熟的做法是给文件名加上哈希戳,哈希戳是文件内容的一一映射,代码更改后,哈希戳也会跟着变,内容没有更改的文件哈希戳也就不会跟着变了

module.exports = {
 output
: {
   filename
: isDev ? '[name].js' : '[name].[chunkhash:4].js',
   
...
 
},
 plugins
: [
   
new Webpack.NamedModulesPlugin(),
 
]
}

qd-cli遗留问题,css的哈希戳跟js的是一样的,不利于浏览器做缓存

图片处理

移动端的雪碧图宽高会带有小数点导致不好处理,暂不考虑(如果你有好的方案,欢迎提供)。过小的图片可以转成base64格式内联进文件内,另外,可以使用image-webpack-loader压缩图片,配置如下:

module.exports = {
 module
: {
   rule
:
   
{
     test
: /\.(png|svga?|jpg|gif)$/,
     use
: [
       
{
         loader
: 'url-loader',
         options
: {
           limit
: 8192,
           fallback
: 'file-loader'
         
}
       
}
     
].concat(isDev ? [] : [
       
{
         loader
: 'image-webpack-loader',
         options
: {
           pngquant
: {
             speed
: 4,
             quality
: '75-90'
           
},
           optipng
: {
             optimizationLevel
: 7
           
},
           mozjpeg
: {
             quality
: 70,
             progressive
: true
           
},
           gifsicle
: {
             interlaced
: false
           
}
         
}
       
}
     
])
   
}
 
}
}

css代码抽离

css的抽取可以减少页面入口的体积,也可以便于css的缓存,使用官方推荐的mini-css-extract-plugin

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module
.exports = {
 module
: {
   rules
: [
     
{
       test
: /\.scss$/,
       use
: [
         isDev
? 'vue-style-loader' : MiniCssExtractPlugin.loader,
         
'css-loader',
         
{
           loader
: 'postcss-loader',
           options
: {
             config
: {
               path
: ownDir('lib/config/postcss.config.js')
             
}
           
}
         
},
         
'sass-loader'
       
]
     
}
   
]
 
}
}

资源预拉取与资源预加载

webpack4.6+支持资源预拉取(prefetch)资源预加载(preload),由于没有尝试成功,这里不做介绍,详情请看code-splitting

提升webpack的打包效率

相比以前,webpack4本身就已经快很多了,这里使用happypackhappypack启动多个进程加速webpack的打包,代码如下:

const os = require('os')
const HappyPack = require('happypack')
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length })
module
.exports = {
 plugins
: [
   
new HappyPack({
     id
: 'eslint',
     verbose
: false,
     loaders
: [
       
...
     
],
     threadPool
: happyThreadPool
   
})
 
]
}

社区很多文章会建议使用ddl打包方式去加速webpack的打包,可以查看:彻底解决Webpack打包性能问题,由于对这个概念不是很理解,暂不做整合

代码目录结构的规划(如何支持多页应用)

为了进一步的编写脚手架,先定好项目的目录结构,这样才会有方向去编写

+ vue-project
+   src
-     index.js
   index
.art       // 每一个xxx.art对应src目录的xxx.js,开发多页应用只需要增加这两个文件
   mock
.config.js  // 必须:mock服务的配置文件
   config
.js       // 必须:配置文件

使用art-template作为模板工具,使用art-template纯粹是因为我比较熟悉,使用其他模板也是可以的,每一个xxx.art对应src目录的xxx.js,开发多页应用只需要增加对应的两个文件就可以了,代码的写作思路是需要entry入口有xxx.js,然后plugins属性有对应的html-webpack-plugin,代码如下:

const glob = require('globa')
const entry = {}
const htmlPlugins = []
glob
.sync(cwd('./src/*.@(js|jsx)')).forEach((filePath) => {
 
const name = path.basename(filePath, path.extname(filePath))
 
const artPath = cwd(`${name}.art`)
 
if (fs.existsSync(artPath)) {
   htmlPlugins
.push(new HtmlWebpackPlugin({
     filename
: `${name}.html`,
     
template: artPath
   
}))
 
}
 entry
[name] = filePath
})
module.exports = {
 entry
,
 plugins
: [...].concat(htmlPlugins)
}

移动端适配方案

目前只考虑移动端项目,说起移动端,首先要考虑的便是适配方案,这里选择大漠大神推荐的vw布局方案:https://www.w3cplus.com/mobile/vw-layout-in-vue.html,配置项有点多,这里不贴了,按照流程走没遇到什么问题

技术选型 - vue,es6

因为我对vue比较熟悉,这里选用了vue,实际上要支持react也只需针对react技术栈做一点点的改动即可,使用vue-loader,参照文档,支持了pug语法,stylus, scss,文档非常的详细,配置项太多了这里不贴了,有兴趣可以直接看源码:qd-cli

支持es6,同时支持async,await,以及装饰器,这两款语法都比较实用,社区很多文章都有介绍

module.exports = {
 presets
: [
   
[
     
'env',
     
// 启动懒加载
     
{
       modules
: false
     
}
   
],
   
'stage-2'
 
],
 plugins
: [
   
'transform-runtime',   // async await
   
'transform-decorators-legacy' // 装饰器
 
]
}

代码规范

使用比较宽松的standard规范,以下是eslint的配置文件

{
 extends
: [
   
'standard',
   
'plugin:vue/essential'
 
],
 rules
: {
   
'no-unused-vars': 1,    // 引入未经使用的模块的时候弹出警告而不是报错中断编译,我特别烦no-unused-vars的报错,特别是在debug的时候- -!
   
'no-new': 0             // 允许使用new
 
},
 
// 不加这一项的话遇到懒加载,async await这样的特性eslint会报错
 parserOptions
: {
   parser
: 'babel-eslint',
   ecmaVersion
: 2017,
   sourceType
: 'module'
 
},
 plugins
: [
   
'vue'
 
]
}

mock数据支持

mock数据很有意义,在与后端定好接口后,前端可以通过mock服务器生成假数据编写显示逻辑,这里使用自己撸的轮子easy-config-mock,很容易继承到现有的脚手架中,支持mock服务的自动重启,支持mockjs库的模拟数据格式,支持使用自定义中间件去编写数据返回逻辑

const EasyConfigMock = require('easy-config-mock');
new EasyConfigMock({
 path
: cwd('mock.config.js')
})

mock.config.js的demo如下:

// mock.config.js
module
.exports = {
 
// common选项不是必须的,可以不用有该选项,内置的配置如下,当然你也可以更改
 common
: {
   
// mock服务的默认端口,如果端口被占用,会自动换一个
   port
: 8018,
   
// 如果你想看一下ajax的loading效果,该配置项可以设置接口的返回延迟
   timeout
: 500,
   
// 如果你想看一下接口请求失败的效果,将rate设置成0就可以了,rate取值范围0~1,代表成功的概率
   rate
: 1,
   
// 默认是true,自动开启mock服务,当然你也可以通过将其设置为false,关闭掉mock服务
   mock
: true
 
},
 
// 普通的api...
 
'/pkApi/getList': {
   code
: 0,
   
'data|5': [{
     
'uid|1000-99999': 999,
     
'name': '@cname'
   
}],
   result
: true
 
},
 
// 中间件api(标准的express中间件),这里你可以书写接口返回逻辑
 
['/pkApi/getOther'] (req, res, next) {
   
const id = req.query.id
   req
.myData = {   // 重要! 将返回数据挂载在req.myData
     
0: {
       code
: 0,
       
'test|1-100': 100
     
},
     
1: {
       code
: 1,
       
'number|+1': 202
     
},
     
2:{
       code
: 2,
       
'name': '@cname'
     
}
   
}[id]
   next
()  // 最后不要忘记手动调用一下next,不然接口就暂停处理了!
 
}
}

实现原理这里有介绍:从零开始搭建一个mock服务

项目集支持

项目集的结构可以如下:

+ vue-projects
-   project1
-   project2
+   project3
+     src
       index
.js
       
...
     index
.art
     config
.js        // 项目配置
     mock
.config.js   // 项目的mock服务
     README
.md        // 项目的说明文档
   
...
-   web_modules        // 项目集的公共模块
   config
.js          // 项目集配置
   README
.md          // 项目集的说明文档

每个小项目都有自己config.js配置文件与README.md说明文档,每个项目集同样都有自己的config.js配置文件与README.md说明文档,小项目的配置文件里的配置可以覆盖掉项目集的配置,另外,还有webpack_modules目录,存放每个项目都可以去使用的公共模块,这样做的好处是同类型项目可以丢在一起,并且相同的依赖,模块可以丢在web_modules中,当web_modules的文件发生变化,需要发版的时候,后续的持续集成可以统一处理,一键全部发版

生成最终配置文件的代码如下:

const R = require('ramda')
const cwd = file => path.resolve(file || '')
const generateConfig = path => {
 
const cfg = require(cwd(path))
 
if (typeof cfg === 'function') {
   
return cfg({})
 
} else {
   
return cfg
 
}
}
module
.exports = {
 getConfig
: R.memoize(_ => {
   let config
= {}
   
// 如果是项目集,项目集也会有个config.js
   
if (fs.existsSync('../config.js')) {
     config
= R.merge(config, generateConfig('../config'))
   
}
   config
= R.merge(config, generateConfig('config.js'))
   
return config
 
})
}

配置项支持

目前只支持以下配置项

// config.js
module
.exports = {
 
// 标准的webpack4的配置,可以覆盖默认配置
 webpack
: {},
 
// 默认的启动端口是8018,这里可以切换
 port
: 8017,
 
// 默认设计图宽度是750,这里可以修改
 viewportWidth
: 750,
 viewportHeight
: 1334,
 
// 生产环境sourcemap使用'source-map'固定不变,开发环境可以通过devtool去设置
 devtool
: 'inline-source-map',
 
// webpack-dev-server代理设置
 proxy
: {},
 
// eslint的规则,因为我自己的习惯,将'no-unused-vars'设成了1,这个配置项可以修改默认的
 rules
: {},
 
// postcss的插件,如果自行定制,本地也需安装一下相应node模块
 postcssPlugin
: {},
 
// .eslintrc的配置项,可以覆盖
 eslintConfig
: {},
 
// babel插件, 默认已经有transform-runtime与transform-decorators-legacy,请不要重复添加
 babelPlugins
: [],
 
// babel preset,默认已经有env与stage-2,请不要重复添加
 babelPresets
: []
}

cli支持

到这里就差不多了,接下来需要将使用webpack搭建的工作流集成成cli,这样做的好处一是可以通过命令行去开发以及构建,同时,可以发布npm社区后,只需一次安装即可,即可多次使用,因为qd-cli内内置vue,vuex,vue-router,axios,jsonp,ramda,jquery等模块,无需二次安装,大大减少了项目体积,简要说明集成成cli是怎么做到以及一些注意点

  • 使用commander搭建cli,可以直接看qd-cli源码,主要代码在bin以及lib/command目录下,也可以参考基于node.js的脚手架工具开发经历

  • webpack的配置项resolve.modules代表当require一个文件,从这些目录去检索,qd-cli的配置项如下

const cwd = p => path.resolve(__dirname, p)
const ownDir = p => path.join(__dirname, p)
module
.exports = {
 resolve
: {
   modules
: [cwd(), cwd('node_modules'), ownDir('node_modules'), cwd('../web_modules')]
 
}

比如: require('jquery')在当前项目目录找不到的话,会前往当前目录下的node_modules,还没找到的话去前往脚手架目录下的node_modules, 以及上一层目录下的web_modules(项目集支持), 由于脚手架内安装了jquery,项目本身就不需要再安装了,直接依赖即可

  • webpack的配置项resolveLoader选项,配置如下:

    resolveLoader: {
     modules
    : [cwd('node_modules'), ownDir('node_modules')]
    },

主要是webpack会报错,说是找不到对应的loader,这里要在查找loader的路径列表里加上脚手架目录下的node_modules

  • 脚手架的package.json中需要带有bin字段

指定qd命令对应的可执行文件的位置

"bin": {
 
"qd": "./bin/cli.js"   // 指示cli的执行文件
}
  • ./bin/cli.js最上面一行

#!/usr/bin/env node

指示用什么程序去启动脚本,我们用的是node

编写种子文件

qd-vue-seed

发布到npm社区

参考如何发布一个自定义Node.js模块到NPM(详细步骤,附Git使用方法:http://www.cnblogs.com/BGOnline/p/6278008.html)

由于qd-cli的名字npm社区不给注册(已经有相似名字的仓库了),我换成了qd-clis😂

qd-cli安装与使用

安装

npm i qd-clis -g
or
yarn
global add qd-clis

window平台请使用管理员权限安装,mac平台请在命令前面加上sudo

如果你不想全局安装的话,拉到本地随意的目录并查看源码的话,可以:(同样要以管理员身份)

git clone git@github.com:nwa2018/qd-cli.git
cd qd
-cli
npm i
/ yarn
npm link

使用

安装完毕后,在命令输入qd即可看到所有命令简介,如下图

如上图,qd-cli具备最基础的生成种子项目,开发与构建三大功能

特性

  • qd-cli内置了vue,vuex,vue-router,axios,jsonp,ramda,jquery,无需二次安装

  • 支持es6语法,支持async,await, 支持装饰器

  • eslint采用standard规范

  • 支持pug语法,stylus, scss

  • 生产环境支持图片自动压缩

  • 支持单页应用,多页应用,支持项目集结构

  • 支持少量的配置项

  • 支持mock服务

  • 生产环境支持压缩,代码分割,懒加载,打哈希戳等

踩过的一些坑

结合vue-loader,mini-css-extract-plugin插件无法抽取出css,css被莫名删掉

webpack guide的tree-shaking章节建议在package.json加上

 "sideEffects": [
   
"*.css"
 
]

以避免css文件被莫名地删掉,实际上结合了vue-loader便会被删掉,解决方案是去掉该选项即可

window平台下无法启动webpackwebpack-dev-server命令

我是使用shelljs去启动打包与开启服务器的动作的,代码如下

// build.js...
shell
.exec(`${ownDir('node_modules/webpack/bin/webpack.js')} --config ${ownDir('lib/webpack/webpack.prod.js')} --progress --report`)
// dev.js...
shell
.exec(`${ownDir('node_modules/webpack-dev-server/bin/webpack-dev-server.js')} --config ${ownDir('webpack/webpack.dev.js')} --color`)

mac平台下没问题,window平台下直接在我的sublime打开了webpack.dev.jswebpack.prod.js- -!,猜测是window平台下系统不知道该以何种程序去启动文件,改成如下即可,加上node

// build.js...
shell
.exec(`node ${ownDir('node_modules/webpack/bin/webpack.js')} --config ${ownDir('lib/webpack/webpack.prod.js')} --progress --report`)
// dev.js...
shell
.exec(`node ${ownDir('node_modules/webpack-dev-server/bin/webpack-dev-server.js')} --config ${ownDir('webpack/webpack.dev.js')} --color`)

eslint无法正确解析import()与async await

参考Parse error with import() #7764 与‘Parsing error: Unexpected token function’ using async/await + ecmaVersion 2017 #8366

一开始报错我以为是babel的问题,花了很多时间去定位- -!在.eslintrc中加上如下配置与安装babel-eslint即可

 parserOptions: {
   parser
: 'babel-eslint',
   ecmaVersion
: 2017,
   sourceType
: 'module'
 
}

babel-core没办法找到.babelrc

.babelrc里加上如下配置,我改成了babel.js,并跟postcss,eslint的配置一起丢到webpack/config/目录下,实际上babel.js就是我们平时编写的.babelrc

{
 
// 传进去babel配置路径
 filename
: ownDir('lib/webpack/config/babel.js'),
}


项目代码:https://github.com/nwa2018/qd-cli


最后,为你推荐


【第1271期】Webpack4+ 多入口程序构建


关于本文

作者:@unravel



    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存