一份關於vue-cli3項目常用項配置

一份關於vue-cli3項目常用項配置

作者:羊先生

轉發鏈接:
https://segmentfault.com/a/1190000022512358

前言

  • 配置全局cdn,包含js、css
  • 開啟Gzip壓縮,包含文件js、css
  • 去掉註釋、去掉console.log
  • 壓縮圖片
  • 本地代理
  • 設置別名,vscode也能識別
  • 配置環境變量開發模式、測試模式、生產模式
  • 請求路由動態添加
  • axios配置
  • 添加mock數據
  • 配置全局less
  • 只打包改變的文件
  • 開啟分析打包日誌
  • vue.config.js

    完整的架構配置

    <code>

    const

    path =

    require

    (

    'path'

    );

    const

    UglifyJsPlugin =

    require

    (

    'uglifyjs-webpack-plugin'

    )

    const

    CompressionWebpackPlugin =

    require

    (

    'compression-webpack-plugin'

    );

    const

    { HashedModuleIdsPlugin } =

    require

    (

    'webpack'

    );

    function

    resolve

    (

    dir

    )

    {

    return

    path.join(__dirname, dir) }

    const

    isProduction = process.env.NODE_ENV ===

    'production'

    ;

    const

    externals = {

    'vue'

    :

    'Vue'

    ,

    'vue-router'

    :

    'VueRouter'

    ,

    'vuex'

    :

    'Vuex'

    ,

    'axios'

    :

    'axios'

    ,

    "element-ui"

    :

    "ELEMENT"

    }

    const

    cdn = {

    dev

    : {

    css

    : [

    'https://unpkg.com/element-ui/lib/theme-chalk/index.css'

    ],

    js

    : [] },

    build

    : {

    css

    : [

    'https://unpkg.com/element-ui/lib/theme-chalk/index.css'

    ],

    js

    : [

    'https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.min.js'

    ,

    'https://cdn.jsdelivr.net/npm/[email protected]/dist/vue-router.min.js'

    ,

    'https://cdn.jsdelivr.net/npm/[email protected]/dist/vuex.min.js'

    ,

    'https://cdn.jsdelivr.net/npm/[email protected]/dist/axios.min.js'

    ,

    'https://unpkg.com/element-ui/lib/index.js'

    ] } }

    module

    .exports = {

    lintOnSave

    :

    false

    ,

    productionSourceMap

    :

    false

    ,

    publicPath

    :

    './'

    ,

    outputDir

    : process.env.outputDir,

    chainWebpack

    :

    config

    =>

    { config.resolve.alias .set(

    '@'

    , resolve(

    'src'

    )) config.module .rule(

    'images'

    ) .test(

    /\.(png|jpe?g|gif|svg)(\?.*)?$/

    ) .use(

    'image-webpack-loader'

    ) .loader(

    'image-webpack-loader'

    ) .options({

    bypassOnDebug

    :

    true

    }) config.optimization.delete(

    'splitChunks'

    ) config.plugin(

    'html'

    ).tap(

    args

    =>

    {

    if

    (process.env.NODE_ENV ===

    'production'

    ) { args[

    0

    ].cdn = cdn.build }

    if

    (process.env.NODE_ENV ===

    'development'

    ) { args[

    0

    ].cdn = cdn.dev }

    return

    args }) config .plugin(

    'webpack-bundle-analyzer'

    ) .use(

    require

    (

    'webpack-bundle-analyzer'

    ).BundleAnalyzerPlugin) },

    configureWebpack

    :

    config

    =>

    {

    const

    plugins = [];

    if

    (isProduction) { plugins.push(

    new

    UglifyJsPlugin({

    uglifyOptions

    : {

    output

    : {

    comments

    :

    false

    , },

    warnings

    :

    false

    ,

    compress

    : {

    drop_console

    :

    true

    ,

    drop_debugger

    :

    false

    ,

    pure_funcs

    : [

    'console.log'

    ] } } }) ) plugins.push(

    new

    CompressionWebpackPlugin({

    algorithm

    :

    'gzip'

    ,

    test

    :

    /\.(js|css)$/

    ,

    threshold

    :

    10000

    ,

    deleteOriginalAssets

    :

    false

    ,

    minRatio

    :

    0.8

    }) ) plugins.push(

    new

    HashedModuleIdsPlugin() ) config.optimization = {

    runtimeChunk

    :

    'single'

    ,

    splitChunks

    : {

    chunks

    :

    'all'

    ,

    maxInitialRequests

    :

    Infinity

    ,

    minSize

    :

    1000

    *

    60

    ,

    cacheGroups

    : {

    vendor

    : {

    test

    :

    /[\\/]node_modules[\\/]/

    , name(

    module

    ) {

    const

    packageName =

    module

    .context.match(

    /[\\/]node_modules[\\/](.*?)([\\/]|$)/

    )[

    1

    ]

    return

    `npm.

    ${packageName.replace(

    '@'

    ,

    ''

    )}

    `

    } } } } }; config.performance = {

    hints

    :

    'warning'

    ,

    maxEntrypointSize

    :

    1000

    *

    500

    ,

    maxAssetSize

    :

    1000

    *

    1000

    ,

    assetFilter

    :

    function

    (

    assetFilename

    )

    {

    return

    assetFilename.endsWith(

    '.js'

    ); } } config.externals = externals; }

    return

    { plugins } },

    pluginOptions

    : {

    'style-resources-loader'

    : {

    preProcessor

    :

    'less'

    ,

    patterns

    : [resolve(

    './src/style/theme.less'

    )] } },

    devServer

    : {

    open

    :

    false

    ,

    host

    :

    '0.0.0.0'

    ,

    port

    :

    6060

    ,

    https

    :

    false

    ,

    hotOnly

    :

    false

    ,

    proxy

    : {

    '^/sso'

    : {

    target

    : process.env.VUE_APP_SSO,

    ws

    :

    true

    ,

    secure

    :

    false

    ,

    changeOrigin

    :

    true

    } } } }/<code>

    html模板配置cdn

    <code> >
    

    <

    html

    lang

    =

    "en"

    >

    <

    head

    >

    <

    meta

    charset

    =

    "utf-8"

    >

    <

    meta

    http-equiv

    =

    "X-UA-Compatible"

    content

    =

    "IE=edge"

    >

    <

    meta

    name

    =

    "viewport"

    content

    =

    "width=device-width,initial-scale=1.0"

    >

    <

    link

    rel

    =

    "icon"

    href

    =

    "favicon.ico"

    >

    <

    title

    >

    <

    %=

    htmlWebpackPlugin.options.title

    %>

    title

    >

    <

    %

    for

    (

    var

    i

    in

    htmlWebpackPlugin.options.cdn

    &&

    htmlWebpackPlugin.options.cdn.css

    ) { %>

    <

    link

    href

    =

    ""

    rel

    =

    "preload"

    as

    =

    "style"

    />

    <

    link

    href

    =

    ""

    rel

    =

    "stylesheet"

    />

    <

    %

    } %>

    head

    >

    <

    body

    >

    <

    noscript

    >

    <

    strong

    >

    We're sorry but

    <

    %=

    htmlWebpackPlugin.options.title

    %>

    doesn't work properly without JavaScript enabled. Please enable it to continue.

    strong

    >

    noscript

    >

    <

    div

    id

    =

    "app"

    >

    div

    >

    <

    %

    for

    (

    var

    i

    in

    htmlWebpackPlugin.options.cdn

    &&

    htmlWebpackPlugin.options.cdn.js

    ) { %>

    <

    script

    src

    =

    ""

    >

    script

    >

    <

    %

    } %>

    body

    >

    html

    >

    /<code>

    開啟Gzip壓縮,包含文件js、css

    <code>

    new

    CompressionWebpackPlugin({

    algorithm:

    'gzip'

    ,

    test:

    /\.(js|css)$/,

    //

    匹配文件名

    threshold:

    10000

    ,

    //

    對超過10k的數據壓縮

    deleteOriginalAssets:

    false

    ,

    //

    不刪除源文件

    minRatio:

    0.8

    //

    壓縮比

    })

    /<code>

    去掉註釋、去掉console.log

    安裝cnpm i uglifyjs-webpack-plugin -D

    <code>

    const

    UglifyJsPlugin

    =

    require('uglifyjs-webpack-plugin')

    new

    UglifyJsPlugin({

    uglifyOptions:

    {

    output:

    {

    comments:

    false

    ,

    //

    去掉註釋

    },

    warnings:

    false

    ,

    compress:

    {

    drop_console:

    true

    ,

    drop_debugger:

    false

    ,

    pure_funcs:

    ['console.log']

    //移除console

    }

    }

    })

    /<code>

    壓縮圖片

    <code>chainWebpack: 

    config

    =>

    { config.module .rule(

    'images'

    ) .test(

    /\.(png|jpe?g|gif|svg)(\?.*)?$/

    ) .use(

    'image-webpack-loader'

    ) .loader(

    'image-webpack-loader'

    ) .options({

    bypassOnDebug

    :

    true

    }) }/<code>

    本地代理

    <code>

    devServer:

    {

    open:

    false

    ,

    //

    自動啟動瀏覽器

    host:

    '0.0.0.0'

    ,

    //

    localhost

    port:

    6060

    ,

    //

    端口號

    https:

    false

    ,

    hotOnly:

    false

    ,

    //

    熱更新

    proxy:

    {

    '^/sso'

    :

    {

    target:

    process.env.VUE_APP_SSO,

    //

    重寫路徑

    ws:

    true

    ,

    //開啟WebSocket

    secure:

    false

    ,

    //

    如果是https接口,需要配置這個參數

    changeOrigin:

    true

    }

    }

    }

    /<code>

    設置vscode 識別別名

    在vscode中插件安裝欄搜索 Path Intellisense 插件,打開settings.json文件添加 以下代碼 "@": "${workspaceRoot}/src",按以下添加

    <code>{
        

    "workbench.iconTheme"

    :

    "material-icon-theme"

    ,

    "editor.fontSize"

    :

    16

    ,

    "editor.detectIndentation"

    :

    false

    ,

    "guides.enabled"

    :

    false

    ,

    "workbench.colorTheme"

    :

    "Monokai"

    ,

    "path-intellisense.mappings"

    : {

    "@"

    :

    "${workspaceRoot}/src"

    } }/<code>

    在項目package.json所在同級目錄下創建文件jsconfig.json

    <code>{
        

    "compilerOptions"

    : {

    "target"

    :

    "ES6"

    ,

    "module"

    :

    "commonjs"

    ,

    "allowSyntheticDefaultImports"

    :

    true

    ,

    "baseUrl"

    :

    "./"

    ,

    "paths"

    : {

    "@/*"

    : [

    "src/*"

    ] } },

    "exclude"

    : [

    "node_modules"

    ] }/<code>

    如果還沒請客官移步在vscode中使用別名@按住ctrl也能跳轉對應路徑

    配置環境變量開發模式、測試模式、生產模式

    在根目錄新建

    .env.development

    <code> 
    

    NODE_ENV

    =

    'development'

    VUE_APP_SSO

    =

    'http://http://localhost:9080'

    /<code>

    .env.test

    <code>

    NODE_ENV

    =

    'production'

    VUE_APP_MODE

    =

    'test'

    VUE_APP_SSO

    =

    'http://http://localhost:9080'

    outputDir

    = test/<code>

    .env.production

    <code>

    NODE_ENV

    =

    'production'

    VUE_APP_SSO

    =

    'http://http://localhost:9080'

    /<code>

    package.json

    <code>

    "scripts"

    : {

    "build"

    :

    "vue-cli-service build"

    ,

    "lint"

    :

    "vue-cli-service lint"

    ,

    "dev"

    :

    "vue-cli-service serve"

    ,

    "test"

    :

    "vue-cli-service build --mode test"

    ,

    "publish"

    :

    "vue-cli-service build && vue-cli-service build --mode test"

    }/<code>

    請求路由動態添加

    router/index.js文件

    <code>

    import

    Vue

    from

    'vue'

    ;

    import

    VueRouter

    from

    'vue-router'

    Vue.use(VueRouter)

    import

    defaultRouter

    from

    './defaultRouter'

    import

    dynamicRouter

    from

    './dynamicRouter'

    ;

    import

    store

    from

    '@/store'

    ;

    const

    router =

    new

    VueRouter({

    routes

    : defaultRouter,

    mode

    :

    'hash'

    , scrollBehavior(to,

    from

    , savedPosition) {

    if

    (savedPosition && to.meta.keepAlive) {

    return

    savedPosition; }

    return

    new

    Promise

    (

    (

    resolve, reject

    ) =>

    { setTimeout(

    ()

    =>

    { resolve({

    x

    :

    0

    ,

    y

    :

    0

    }) },

    200

    ) }) } })

    const

    selfaddRoutes =

    function

    (

    params

    )

    { router.matcher =

    new

    VueRouter().matcher; router.addRoutes(params); } router.beforeEach(

    (

    to,

    from

    , next

    ) =>

    {

    const

    { hasRoute } = store.state;

    if

    (hasRoute) { next() }

    else

    { dynamicRouter(to,

    from

    , next, selfaddRoutes) } })

    export

    default

    router;/<code>

    dynamicRouter.js

    <code>

    import

    http

    from

    '@/http/request'

    ;

    import

    defaultRouter

    from

    './defaultRouter'

    import

    store

    from

    '@/store'

    const

    menusMap =

    function

    (

    menu

    )

    {

    return

    menu.map(

    v

    =>

    {

    const

    { path, name, component } = v

    const

    item = { path, name,

    component

    :

    ()

    =>

    import

    (

    `@/

    ${component}

    `

    ) }

    return

    item; }) }

    const

    addPostRouter =

    function

    (

    to, from, next, selfaddRoutes

    )

    { http.windPost(

    '/mock/menu'

    ) .then(

    menu

    =>

    { defaultRouter[

    0

    ].children.push(...menusMap(menu)); selfaddRoutes(defaultRouter); store.commit(

    'hasRoute'

    ,

    true

    ); next({ ...to,

    replace

    :

    true

    }) }) }

    export

    default

    addPostRouter;/<code>

    defaultRouter.js 默認路由

    <code>

    const

    main =

    r

    =>

    require

    .ensure([],

    ()

    =>

    r(

    require

    (

    '@/layout/main.vue'

    )),

    'main'

    )

    const

    index =

    r

    =>

    require

    .ensure([],

    ()

    =>

    r(

    require

    (

    '@/view/index/index.vue'

    )),

    'index'

    )

    const

    about =

    r

    =>

    require

    .ensure([],

    ()

    =>

    r(

    require

    (

    '@/view/about/about.vue'

    )),

    'about'

    )

    const

    detail =

    r

    =>

    require

    .ensure([],

    ()

    =>

    r(

    require

    (

    '@/view/detail/detail.vue'

    )),

    'detail'

    )

    const

    error =

    r

    =>

    require

    .ensure([],

    ()

    =>

    r(

    require

    (

    '@/view/404/404.vue'

    )),

    'error'

    );

    const

    defaultRouter = [ { path:

    "/"

    , component: main, redirect: { name:

    "index"

    }, children:[ { path:

    '/index'

    , component: index, name:

    'index'

    , meta: { title:

    'index'

    } }, { path:

    '/about'

    , component: about, name:

    'about'

    , meta: { title:

    'about'

    } }, { path:

    '/detail'

    , component: detail, name:

    'detail'

    , meta: { title:

    'detail'

    } } ] }, { path:

    '/404'

    , component: error, name:

    '404'

    , meta: { title:

    '404'

    } } ]

    export

    default

    defaultRouter;/<code>

    axios配置

    <code>

    import

    axios

    from

    "axios"

    ;

    import

    merge

    from

    'lodash/merge'

    import

    qs

    from

    'qs'

    const

    http = axios.create({

    timeout

    :

    1000

    *

    30

    ,

    withCredentials

    :

    true

    , }); http.interceptors.request.use(

    function

    (

    config

    )

    {

    return

    config; },

    function

    (

    error

    )

    {

    return

    Promise

    .reject(error); }); http.interceptors.response.use(

    response

    =>

    {

    if

    (response.data && (response.data.code ===

    401

    )) { }

    return

    response }, error => {

    return

    Promise

    .reject(error) }) http.adornUrl =

    (

    url

    ) =>

    {

    return

    url; } http.adornParams =

    (

    params = {}, openDefultParams =

    true

    ) =>

    {

    var

    defaults = {

    t

    :

    new

    Date

    ().getTime() }

    return

    openDefultParams ? merge(defaults, params) : params } http.adornData =

    (

    data = {}, openDefultdata =

    true

    , contentType =

    'json'

    ) =>

    {

    var

    defaults = {

    t

    :

    new

    Date

    ().getTime() } data = openDefultdata ? merge(defaults, data) : data

    return

    contentType ===

    'json'

    ?

    JSON

    .stringify(data) : qs.stringify(data) } http.windPost =

    function

    (

    url, params

    )

    {

    return

    new

    Promise

    (

    (

    resolve, reject

    ) =>

    { http.post(http.adornUrl(url), qs.stringify(params)) .then(

    res

    =>

    { resolve(res.data) }) .catch(

    error

    =>

    { reject(error) }) }) } http.windJsonPost =

    function

    (

    url, params

    )

    {

    return

    new

    Promise

    (

    (

    resolve, reject

    ) =>

    { http.post(http.adornUrl(url), http.adornParams(params)) .then(

    res

    =>

    { resolve(res.data) }) .catch(

    error

    =>

    { reject(error) }) }) } http.windGet =

    function

    (

    url, params

    )

    {

    return

    new

    Promise

    (

    (

    resolve, reject

    ) =>

    { http.get(http.adornUrl(url), {

    params

    : params }) .then(

    res

    =>

    { resolve(res.data) }) .catch(

    error

    =>

    { reject(error) }) }) } http.upLoadPhoto =

    function

    (

    url, params, callback

    )

    {

    let

    config = {}

    if

    (callback !==

    null

    ) { config = {

    onUploadProgress

    :

    function

    (

    progressEvent

    )

    { callback(progressEvent) } } }

    return

    new

    Promise

    (

    (

    resolve, reject

    ) =>

    { http.post(http.adornUrl(url), http.adornParams(params), config) .then(

    res

    =>

    { resolve(res.data) }) .catch(

    error

    =>

    { reject(error) }) }) }

    export

    default

    http;/<code>

    添加mock數據

    <code>

    const

    Mock =

    require

    (

    'mockjs'

    )

    const

    Random = Mock.Random

    const

    produceNewsData =

    function

    (

    )

    {

    let

    newsList = []

    for

    (

    let

    i =

    0

    ; i

    3

    ; i++) {

    let

    newNewsObject = {}

    if

    (i ===

    0

    ){ newNewsObject.path =

    '/add/article'

    ; newNewsObject.name =

    'add-article'

    ; newNewsObject.component =

    'modules/add/article/article'

    ; }

    if

    (i ===

    1

    ){ newNewsObject.path =

    '/detail/article'

    ; newNewsObject.name =

    'detail-article'

    ; newNewsObject.component =

    'modules/detail/article/article'

    }

    if

    (i ===

    2

    ){ newNewsObject.path =

    '/edit/article'

    ; newNewsObject.name =

    'edit-article'

    ; newNewsObject.component =

    'modules/edit/article/article'

    } newsList.push(newNewsObject) }

    return

    newsList; } Mock.mock(

    '/mock/menu'

    , produceNewsData)/<code>

    配置全局less

    <code>

    pluginOptions

    : {

    'style-resources-loader'

    : {

    preProcessor

    :

    'less'

    ,

    patterns

    : [resolve(

    './src/style/theme.less'

    )] } }/<code>

    只打包改變的文件

    安裝cnpm i webpack -D

    <code>

    const

    { HashedModuleIdsPlugin } =

    require

    (

    'webpack'

    ); configureWebpack:

    config

    =>

    {

    const

    plugins = []; plugins.push(

    new

    HashedModuleIdsPlugin() ) }/<code>

    開啟分析打包日誌

    安裝cnpm i webpack-bundle-analyzer -D

    <code>chainWebpack: 

    config

    =>

    { config .plugin(

    'webpack-bundle-analyzer'

    ) .use(

    require

    (

    'webpack-bundle-analyzer'

    ).BundleAnalyzerPlugin) }/<code>

    完整代碼

    一份關於vue-cli3項目常用項配置

    點擊獲取完整代碼
    github:https://github.com/hangjob/vue-admin

    推薦Vue和React學習資料文章:

    怎樣為你的 Vue.js 單頁應用提速

    聊聊昨晚尤雨溪現場針對Vue3.0 Beta版本新特性知識點彙總

    【新消息】Vue 3.0 Beta 版本發佈,你還學的動麼?

    Vue真是太好了 壹萬多字的Vue知識點 超詳細!

    Vue + Koa從零打造一個H5頁面可視化編輯器——Quark-h5

    深入淺出Vue3 跟著尤雨溪學 TypeScript 之 Ref 【實踐】

    手把手教你深入淺出vue-cli3升級vue-cli4的方法

    Vue 3.0 Beta 和React 開發者分別槓上了

    手把手教你用vue drag chart 實現一個可以拖動 / 縮放的圖表組件

    Vue3 嚐鮮

    總結Vue組件的通信

    手把手讓你成為更好的Vue.js開發人員的12個技巧和竅門【實踐】

    Vue 開源項目 TOP45

    2020 年,Vue 受歡迎程度是否會超過 React?

    尤雨溪:Vue 3.0的設計原則

    使用vue實現HTML頁面生成圖片

    實現全棧收銀系統(Node+Vue)(上)

    實現全棧收銀系統(Node+Vue)(下)

    vue引入原生高德地圖

    Vue合理配置WebSocket並實現群聊

    多年vue項目實戰經驗彙總

    vue之將echart封裝為組件

    基於 Vue 的兩層吸頂踩坑總結

    Vue插件總結【前端開發必備】

    Vue 開發必須知道的 36 個技巧【近1W字】

    構建大型 Vue.js 項目的10條建議

    深入理解vue中的slot與slot-scope

    手把手教你Vue解析pdf(base64)轉圖片【實踐】

    使用vue+node搭建前端異常監控系統

    推薦 8 個漂亮的 vue.js 進度條組件

    基於Vue實現拖拽升級(九宮格拖拽)

    手摸手,帶你用vue擼後臺 系列二(登錄權限篇)

    手摸手,帶你用vue擼後臺 系列三(實戰篇)

    前端框架用vue還是react?清晰對比兩者差異

    Vue組件間通信幾種方式,你用哪種?【實踐】

    淺析 React / Vue 跨端渲染原理與實現

    10個Vue開發技巧助力成為更好的工程師

    手把手教你Vue之父子組件間通信實踐講解【props、$ref 、$emit】

    1W字長文+多圖,帶你瞭解vue的雙向數據綁定源碼實現

    深入淺出Vue3 的響應式和以前的區別到底在哪裡?【實踐】

    乾貨滿滿!如何優雅簡潔地實現時鐘翻牌器(支持JS/Vue/React)

    基於Vue/VueRouter/Vuex/Axios登錄路由和接口級攔截原理與實現

    手把手教你D3.js 實現數據可視化極速上手到Vue應用

    吃透 Vue 項目開發實踐|16個方面深入前端工程化開發技巧【上】

    吃透 Vue 項目開發實踐|16個方面深入前端工程化開發技巧【中】

    吃透 Vue 項目開發實踐|16個方面深入前端工程化開發技巧【下】

    Vue3.0權限管理實現流程【實踐】

    後臺管理系統,前端Vue根據角色動態設置菜單欄和路由

    13個精選的React JS框架

    深入淺出畫圖講解React Diff原理【實踐】

    【React深入】React事件機制

    Vue 3.0 Beta 和React 開發者分別槓上了

    手把手深入Redux react-redux中間件設計及原理(上)【實踐】

    手把手深入Redux react-redux中間件設計及原理(下)【實踐】

    前端框架用vue還是react?清晰對比兩者差異

    為了學好 React Hooks, 我解析了 Vue Composition API

    【React 高級進階】探索 store 設計、從零實現 react-redux

    寫React Hooks前必讀

    深入淺出掌握React 與 React Native這兩個框架

    可靠React組件設計的7個準則之SRP

    React Router v6 新特性及遷移指南

    用React Hooks做一個搜索欄

    你需要的 React + TypeScript 50 條規範和經驗

    手把手教你繞開React useEffect的陷阱

    淺析 React / Vue 跨端渲染原理與實現

    React 開發必須知道的 34 個技巧【近1W字】

    三張圖詳細解說React組件的生命週期

    手把手教你深入淺出實現Vue3 & React Hooks新UI Modal彈窗

    手把手教你搭建一個React TS 項目模板

    全平臺(Vue/React/微信小程序)任意角度旋圖片裁剪組件

    40行代碼把Vue3的響應式集成進React做狀態管理

    手把手教你深入淺出React 迷惑的問題點【完整版】

    React可用於哪些Web開發場景?具體怎麼做?

    作者:羊先生

    轉發鏈接:
    https://segmentfault.com/a/1190000022512358


    分享到:


    相關文章: