相信大家在使用nextjs的時候,難免遇到一些坑。其實可能大部分原因在於 nextjs 做了很多封裝,我們可能不能第一時間搞清楚包括它相關的所有配置,比如其中的webpack配置。我前面也寫過 SSR 實現的文章和簡單的輪子《實現ssr服務端渲染》,也知道 SSR 要實現為 nextjs 這樣的三方框架,還是會需要經歷很復雜編碼的。
總歸有時候遇到問題,在網上也查不到一個正確的解決方案。比如,我為此頭痛幾天的 antd-mobile 按需加載,最開始我無法正常使用,就只能全局引入 antd-mobile的min.css,這導致我要在頁面加載 164k 的 css 文件,我們使用 nextjs 就是為了提升加載速度,這種情況不能忍啊!
言歸正傳,先說說我遇到的問題,我使用了 antd-mobile 並且需要對它進行按需加載,下面是官方的文檔,推薦我們使用 babel-plugin-import。
按照 nextjs 官方文檔我們在 .babelrc 中添加插件
{ "presets": ["next/babel"], "plugins": [ ["import", { "libraryName": "antd-mobile", "style": true }] ] }
可當我運行的時候報錯了,報錯如下。最開始感到奇怪,我並沒有引入這個包,后來發現其實是 antd-mobile 中有引入它。但是為什么會報錯呢, 便想到應該是 webpack loader 的問題,我認為是 loader 排除了node_modules。(這里解釋一下,node_modules 中的包本應該都是打包好的,但是一些情況下,我們是直接引入源代碼中的模塊的,那這樣的話我們就需要讓我們的 loader 解析 node_modules 中的代碼,然而有些默認配置就是排除了 node_modules 的,這樣就會導致無法解析)。
然后我在 next.config.js 中,定義 webpack 方法,打印出 webpack 配置。 nextjs 中的 webpack 配置大致是引入了一個 next-babel-loader 這樣的 loader,而我們使用next-css、next-less或者next-sass等插件,相關的 loader 會被 push 到 rules 中。 核心的loader 就是 next-babel-loader。然而我在其參數中並沒有發現 exclude, 到是有 include,而后我往 include 里添加 node_modules 下需要的組件正則,發現並沒有效果。而后我經歷了各種痛苦,嘗試過各種方面的辦法,網上也查不出解決方案。好,跳過心酸的部分。
再后來我開始仔細的一個個看官方的插件,我找到了它:next-transpile-modules,從名稱上來看似乎和我想要的有點關系。https://github.com/martpie/next-transpile-modules。
一看文檔果然,它就是我要找的,它就是解決 node_modules 中代碼不被 loader 解析的問題。我使用了它,這時報錯信息變了(其實后來我弄比較清楚以后就沒有報錯了,可能當時配置改的比較多,哪里影響到了),我覺得似乎起到作用了,但是還是會報錯。於是我便看了一下它的代碼,我終於發現了 webpack.externals 這個配置,原來是這個地方排除了解析外部依賴。如果我們使用插件 transpile 並配置好 transpileModules: ["antd-mobile"],transpile 內部會生成 includes 正則,在 externals 執行時,會排除掉我們配置的 node_modules 模塊,因此 antd-mobile 就能被正常解析了,代碼如下
if (config.externals) { config.externals = config.externals.map(external => { if (typeof external !== 'function') return external; return (ctx, req, cb) => { return includes.find(include => req.startsWith('.') ? include.test(path.resolve(ctx, req)) : include.test(req) ) ? cb() : external(ctx, req, cb); }; }); }
而后它又添加了一個 next-babel-loader 到 rules 中,現在其實有兩個 next-babel-loader 在 webpack 配置中。我認為這個配置是多余的,並且就是之前我可能哪里沒配置對,這個多余的 loader 讓我編譯報錯了,我把它生成的多余 loader 刪除才沒有報錯的。
最后在我完全能正常運行的時候,還是嘗試刪除了它,發現並沒有報錯,因為從理論上來說,這個重復的loader本身也沒有用,因此我給作者提了一個建議,建議去掉這個新loader, 對方說再認真看看。這里:https://github.com/martpie/next-transpile-modules/issues/32。 (事實證明我理解錯了,請看文章后文詳情)
// Add a rule to include and parse all modules config.module.rules.push({ test: /\.+(js|jsx|ts|tsx)$/, loader: options.defaultLoaders.babel, include: includes });
我當前使用的 next 是8.x,在6.x里,我看了下它確實是用的 exclude 來排除的 node_modules,到 8 以后改為 externals 了,一定有它官方的道理吧。如果你用的是6.x,你可以嘗試修改 exclude,不過建議大家都升級為 8 吧,很平滑的。
第二個問題,可能也是大家比較常見的,那就是 cssModules。官方代碼是這樣的
// next.config.js const withCSS = require('@zeit/next-css') module.exports = withCSS({ cssModules: true, cssLoaderOptions: { importLoaders: 1, localIdentName: "[local]___[hash:base64:5]", } })
完全沒有問題,可以正常使用。只是 antd-mobile 的 class 名稱也被 cssModules 給改了,但是組件 dom 中的 class 名稱並沒有被修改,這樣樣式就不起作用了。ok,沒有問題,這個簡單,我們使用 css-loader api 中的 options.getLocalIdent,來控制修改 class 名稱。代碼大致如下
const cssLoaderGetLocalIdent = require("css-loader/lib/getLocalIdent.js"); /*.....*/ cssLoaderOptions: { localIdentName: "[local]___[hash:base64:5]", getLocalIdent: (context, localIdentName, localName, options) => { let hz = context.resourcePath.replace(context.rootContext, ""); if (/node_modules/.test(hz)) { return localName; } else { return cssLoaderGetLocalIdent( context, localIdentName, localName, options ); } } },
通過閱讀 css-loader 源碼,發現其內部運行過程,它內部有一個 css-loader/lib/getLocalIdent.js 方法,如果用戶自定義了 getLocalIdent 方法,它在編譯 cssmodules 時,便會用用戶定義的方法,否則使用自帶的方法。我的想法就是通過自定義 getLocalIdent, 正則判斷 node_modules,也就是當前樣式如果是來自於 node_modules 中文件的話,我返回它本身的名稱,就是不改動它,而它是我們的源碼的話,我執行 css-loader 本身的 getLocalIdent 方法。這樣就既使我們自己的代碼能被 cssmodules,而三方庫的代碼不被 cssmodules 影響。
最后附上兩個配置文件 .babelrc 、 next.config.js 和 postcss.config.js
//.babelrc { "presets": ["next/babel"], "plugins": [ ["import", { "libraryName": "antd-mobile", "style": true }] ] }
//next.config.js const withLess = require("@zeit/next-less"); const withCss = require("@zeit/next-css"); const withPlugins = require("next-compose-plugins"); const withTM = require('next-transpile-modules'); const cssLoaderGetLocalIdent = require("css-loader/lib/getLocalIdent.js"); module.exports = withPlugins([withCss, withLess,withTM], { transpileModules: ["antd-mobile"], cssModules: true, cssLoaderOptions: { localIdentName: "[local]___[hash:base64:5]", getLocalIdent: (context, localIdentName, localName, options) => { let hz = context.resourcePath.replace(context.rootContext, ""); if (/node_modules/.test(hz)) { return localName; } else { return cssLoaderGetLocalIdent( context, localIdentName, localName, options ); } } } });
//postcss.config.js const pxtorem = require("postcss-pxtorem"); module.exports = { plugins: [ pxtorem({ rootValue: 50, unitPrecision: 5, propList: ["*"], selectorBlackList: [/^\.nop2r/, /^\.am/],//排除antd樣式 replace: true, mediaQuery: false, minPixelValue: 0 }) ] }
pxtorem是轉換px為rem,有的需要的自取,如果此方案解決了你的問題,點個贊吧~
注意:
如果還會存在 antd 的報錯,在 next.config.js 中添加 webpack 配置方法去掉 next-transpile-modules 額外添加的 loader,清空其 include。 這個多余的 loader 確實會導致 bug,或許你在使用的時候此包的代碼已經更新。
//next.config.js const withLess = require("@zeit/next-less"); const withCss = require("@zeit/next-css"); const withPlugins = require("next-compose-plugins"); const withTM = require('next-transpile-modules'); const cssLoaderGetLocalIdent = require("css-loader/lib/getLocalIdent.js"); module.exports = withPlugins([withCss, withLess,withTM], {
lessLoaderOptions: {//如果是antd就需要,antd-mobile可以不用
javascriptEnabled: true
}, transpileModules: ["antd-mobile"], cssModules: true, cssLoaderOptions: { localIdentName: "[local]___[hash:base64:5]", getLocalIdent: (context, localIdentName, localName, options) => { let hz = context.resourcePath.replace(context.rootContext, ""); if (/node_modules/.test(hz)) { return localName; } else { return cssLoaderGetLocalIdent( context, localIdentName, localName, options ); } } }, webpack(config){ config.module.rules.forEach(item=>{ if(item.loader&&item.loader.loader){ item.include = [] } }) return config } });
終解:
后來我終於想清楚了,首先 next-transpile-modules 的目的就是讓 node_modules 中的包可以使用 next-babel-loader ,它的文檔第一句就是這個意思,我當時理解錯誤了。
其次我們再來說說 webpack.externals 這個配置,比如 nextjs 默認就是如下這樣配置的,它把 node_modules 下的 js 作為一個公共的js來處理,當這樣配置以后,webpack 就不會去分析 node_modules 下的 js 的依賴了。
比如我自己在 node_modules 里寫一個文件夾 @test,里面是一個 index.js,index.js require了同級的 b.js,然后我們在 nextjs 的項目代碼里引入 @test/index.js ,編譯時就會報錯,報錯的行就在 require('b.js') 這里。
再來說說 next-transpile-modules, 它做了兩個事情,第一是從 nextjs 默認的 externals 中,排除掉我們定義的 transpileModules: ["antd-mobile"],這樣 antd-mobile 中的 js 就會被 webpack 正常解析依賴了。而后新建了一個 next-babel-loader ,include 的值是 transpileModules 配置的 ["antd-mobile"]。 由於我們的 antd-mobile 中的代碼不需要被 next-babel-loader 解析,甚至如果使用 next-babel-loader 解析就會報錯,因此我前面的配置把它添加的 loader 的 include 給清空了,這樣所有的配置就 ok 了。因此我們只需要它其中的 externals 功能,ok, next.config.js 最終代碼如下( .babelrc 和 postcss.config.js 參照上面不變
const withLess = require("@zeit/next-less"); const withCss = require("@zeit/next-css"); const withPlugins = require("next-compose-plugins"); const cssLoaderGetLocalIdent = require("css-loader/lib/getLocalIdent.js"); const path = require('path'); module.exports = withPlugins([withLess,withCss], { lessLoaderOptions : {//如果是antd就需要,antd-mobile不需要 javascriptEnabled : true }, cssModules: true, cssLoaderOptions: { camelCase: true, localIdentName: "[local]___[hash:base64:5]", getLocalIdent: (context, localIdentName, localName, options) => { let hz = context.resourcePath.replace(context.rootContext, ""); if (/node_modules/.test(hz)) { return localName; } else { return cssLoaderGetLocalIdent( context, localIdentName, localName, options ); } } }, webpack(config){ if(config.externals){ const includes = [/antd-mobile/]; config.externals = config.externals.map(external => { if (typeof external !== 'function') return external; return (ctx, req, cb) => { return includes.find(include => req.startsWith('.') ? include.test(path.resolve(ctx, req)) : include.test(req) ) ? cb() : external(ctx, req, cb); }; }); } return config; } });
如果沒有實現按需加載,最終打包出來的文件會很大,因為包含了整個antd庫。
一點重要提示
需要安裝less,npm i --save less。本以為 next/less 應該有 less 依賴,結果還是需要安裝一下 less,否則 loader 會報錯!
發現的一些問題記錄
1.頁面切換樣式問題
開發環境頁面 A 切換到 B 后,B 沒有樣式。這個情況是在開發模式下才有。
比如我初次啟動應用之后,訪問 A,A 發現沒登錄訪 B,這個時候 B 樣式加載不出來,頁面沒樣式。如果我在 B 頁面刷新一次,讓服務端渲染一次,然后 A 再跳到 B 就有樣式了。我發現在第一次從 A 跳到 B 的時候,有一個類似這樣的一個請求:/_next/static/chunks/styles.js?ts=1557217006063,就是 B 樣式的熱更新文件。但是實際 _next/static/css/styles.chunk.css 這個文件里沒有成功載入 B 的樣式。而當我們用服務端渲染一次 B 頁面,也就是在 B 的路由下刷新一次。而后的 chunk.css 就有樣式了。
我們再看看生產環境,生產環境,nextjs 會把所有依賴的 css 打包到一個 chunk.css 文件中,在首次渲染的時候,整個應用的所有樣式都已經被載入了,比如 A 和 B 的樣式都有了。所以在切換頁面的時候,樣式都沒問題。
依照這個情況看來,開發環境下,樣式是被加載到運行時的內存中的,一旦有用服務端渲染 A 頁面,A 的樣式就會被添加進服務端內存中,再用服務端渲染一次 B 頁面,而后請求 chunk.css 就才會有兩個頁面的樣式。問題在於開發環境下的熱更新沒有起到作用,應該是一個官方的bug。
此 issue 說不是 next 核心的 bug,是三方插件的問題,那么問題應該在next-css, https://github.com/zeit/next.js/issues/4732 。