前言
上一节对资源稍微作了处理,接下来把生产环境配置一下,对js进行按动态加载处理,开发中很多情况下,需要基于路由分包或者对一些比较少用到的库进行分包。
前端配置生产环境
前面webpack.base.js是把服务端和客户端的相同部分抽离出来,现在既然有了生产环境配置,那么就修改一下webpack.base.js,把这个作为客户端开发环境和生产环境的相同部分抽离。而服务端,则通过process.env.NODE_ENV进行判断开发环境和生产环境。
目录结构:
├── webpack // 配置文件夹
│ ├── loader //自定义loader文件夹
│ ├── scripts //node脚本文件夹
│ ├── webpack.base.js // 前端开发环境和生产环境相同部分抽离
│ ├── webpack.client.dev.js // 前端开发环境配置文件
│ ├── webpack.client.pro.js // 前端生产环境配置文件
│ ├── webpack.server.js // 服务端配置文件
安装依赖
$ npm i optimize-css-assets-webpack-plugin webpack-manifest-plugin -D
optimize-css-assets-webpack-plugin是用来压缩css文件用的,而webpack-manifest-plugin是用来生成资源映射表json用的,像生成环境,前端生成的文件肯定是带着hash的嘛,那服务端像获得相应的正确文件名,就可以通过这个plugin生成的json获取到。
先用webpack.base.js把前端开发环境和生产环境的相同配置抽离出来。
module.exports = {
entry: resolvePath('../src/client/index.js'),
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [{
test: /.jsx?$/,
use: 'babel-loader',
exclude: /node_modules/
}, {
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}, {
test: /\.(jpg|png|jpeg)$/,
use: [{
loader: 'url-loader',
options: {
limit: 10240,
name: 'img/[name].[hash:7].[ext]',
esModule: false
}
}]
}]
},
optimization: {
splitChunks: {
cacheGroups: {
libs: {
test: /node_modules/,
chunks: 'initial',
name: 'libs'
}
}
}
},
plugins: [
new webpack.DefinePlugin({
'__isServer': false
}),
new CleanWebpackPlugin()
]
}
那么前端开发环境配置就比较简单了,除却base部分,就只有mode output plugins和devtool。这里devtool是为了方便开发环境出现错误调试使用,我用的是cheap-module-eval-source-map。
module.exports = merge(base, {
mode: 'development',
output: {
filename: 'index.js',
path: resolvePath('../dist/client')
},
devtool: 'cheap-module-eval-source-map',
plugins: [
new MiniCssExtractPlugin({
filename: 'index.css'
}),
]
});
至于前端的生产环境,主要就是增加hash,生产资源映射表,进行css代码压缩,顺便一提,webpack4在mode = production的时候,已经做了很多处理,比如js压缩,所以我们就可以省去对js压缩处理步骤了。
module.exports = merge(base, {
mode: 'production',
output: {
filename: 'index-[contentHash:10].js',
path: resolvePath('../dist/client')
},
plugins: [
new MiniCssExtractPlugin({
filename: 'index-[contentHash:10].css'
}),
new webpackManifestPlugin({
fileName: '../mainfest.json', // 这个是fileName,上面的是filename,迷惑
filter: ({ name }) => {
const ext = name.slice(name.lastIndexOf('.'));
return ext === '.js' || ext === '.css';
}
}),
new OptimizeCssAssetsWebpackPlugin()
]
})
webpackManifestPlugin设置filter的目的,是为了仅仅生产css和js文件的资源映射表,对于其他类型文件,例如img则已经被正确引用。
app.use(express.static('./dist/client'));相当于是在dist/client目录下进行相对路径引用,所以图片ssr输出没啥问题。
那么现在,前端就可以打包生成文件&资源映射表:
同时,css文件也被压缩辽:
服务端配置生产环境
那么现在对于服务端又有了新的问题,服务端在html拼接的时候,是该引入什么文件名,即引入index.js还是index-xxx.js。
这个其实可以直接通过webpack.definePlugin设置全局常量,在不同NODE_ENV,引入不同文件名,即可解决。
在package.json命令上,设置NODE_ENV=production,即可。
同时,针对路径,设置alias,方便引入文件。
const isDev = process.env.NODE_ENV === 'development';
module.exports = {
mode: process.env.NODE_ENV,
target: 'node',
entry: resolvePath('../src/server/index.js'),
output: {
filename: 'index.js',
path: resolvePath('../dist/server')
},
externals: [nodeExternals()],
module: {
rules: [{
test: /.css$/,
loader: './webpack/loader/css-remove-loader'
}, {
test: /.jsx?$/,
use: 'babel-loader',
exclude: /node_modules/
}, {
test: /.(jpg|jpeg|png)$/,
use: {
loader: 'url-loader',
options: {
limit: 10240,
name: 'img/[name].[hash:7].[ext]',
esModule: false
}
}
}]
},
resolve: {
alias: {
'@dist': resolvePath('../dist'),
}
},
plugins: [
new webpack.DefinePlugin({
'__isServer': true,
'__isDev': isDev
}),
new CleanWebpackPlugin()
]
};
服务端webpack配置文件中,通过process.env.NODE_ENV==='development',设置了全局常量__isDev。那么在html拼接文件中,就可以通过该全局变量进行判断,是引入index.js还是引入index-xxx.js。
export const handleHtml = ({ reactStr, initialData, styles }) => {
const jsKeys = ['libs.js', 'main.js'];
const cssKeys = ['main.css'];
let jsValues = [];
let cssValues = [];
if (__isDev) {
jsValues = ['libs.index.js', 'index.js'];
cssValues = ['index.css'];
} else {
const mainfest = require('@dist/mainfest.json');
jsValues = jsKeys.map(v => mainfest[v]);
cssValues = cssKeys.map(v => mainfest[v]);
};
return `<html>.....`;
};
代码还是看起来,清晰易懂的,就不做解释了。
最后在html中,引入我们的jsValues和cssValues,即可。
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
${cssValues.map(v => `<link rel="stylesheet" href="${v}"></link>`).join('')}
</head>
<body>
<div id="root">${reactStr}</div>
<textarea id="textareaSsrData" style="display: none">${initialData}</textarea>
</body>
${jsValues.map(v => `<script type="text/javascript" src="${v}"></script>`).join('')}
</html>`
配置运行脚本&运行
最后,我们在package.json里,配置我们的前端打包&服务端打包等命令。
{
"client:build": "webpack --config ./webpack/webpack.client.pro.js",
"server:build": "NODE_ENV=production webpack --config ./webpack/webpack.server.js",
"build": "npm run client:build && npm run server:build",
"start": "node ./dist/server/index.js"
}
同时修改之前的server:dev,毕竟项目改成了传入NODE_ENV形式。
{
"server:dev": "NODE_ENV=development webpack --watch --config ./webpack/webpack.server.js"
}
顺便一提,如果觉得写&和&&挺麻烦的,可以用一些node任务管理模块,例如:npm-run-all,script-runner等等。
那么至此,就可以开始编译,并运行起来。
JS动态加载
js动态加载主要通过import()方法,该方法使用promise回调,获取我们要异步加载的模块。被import()加载的组件,会被达成单独的js文件 ,但是有一些情况是不会处理成单独文件。
比如在CommonJS规范下,如果引入了文件的某个组件 ,但是对于其余组件进行动态加载,就会失败,其他组件也已经被打入到了main.js。这个是因为CommonJS模块是动态定义的,分析他们比较困难,这也是为什么使用CommonJS会导致程序包变大,一般解决方法是采用tree sharking。
安装依赖
$ npm i @babel/{plugin-proposal-class-properties,plugin-syntax-dynamic-import} -D
并添加到.babelrc里
{
"plugins": [
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-proposal-class-properties"
]
}
React.lazy
对于页面组件比较少用到的,但是很占js文件体积的组件,可以采用React.lazy去异步加载组件。lazy其实就是对动态导入方式的一种优化方法 ,用法比较简单,没啥好说的。
const Comp = React.lazy(() => import('./components/child'));
const Index = () => {
const [show, setShow] = useState(false);
const click = () => setShow(true);
return (
<div>
<div onClick={click}>page: Orange</div>
{
show ?
<React.Suspense fallback={<div>loading</div>}>
<Comp />
</React.Suspense> : null
}
</div>
)
};
基于路由进行动态加载
对于路由的组件而言,我们应该是传入函数()=>import(),在组件内部去调用这个函数。在回调中,再去渲染出异步加载的组件。
我这里是采用函数,传入加载函数返回组件,然后把加载函数,作为组件的静态方法,方便于后面服务端ssr的时候,直接加载出这个组件。
const asyncComp = (load) => {
return class AsyncComp extends React.Component {
constructor(props) {
super(props)
this.state = {
Comp: null
};
};
static _load = load;
asyncLoad = async () => {
const { default: Comp } = await AsyncComp._load();
this.setState({ Comp });
};
componentDidMount() {
if (!this.state.Comp) {
console.log('加载异步组件');
this.asyncLoad();
}
};
render() {
const { Comp } = this.state;
return (
Comp ? <Comp {...this.props} /> : <div>loading</div>
)
};
};
};
那么对于路由配置文件而言,需要进行修改,对于需要异步加载的组件,采用以下修改
import { Fruit } from '../pages/index';
export default [{
component: Fruit,
path: '/',
exact: true,
name: 'Fruits'
}, {
component: asyncComp(() => import('../pages/apple/index')),
path: '/apple',
exact: true,
name: 'Apple'
},//...
]
但是采用以上配置,并没有打出若干个包
网上很多说法是,在webpack打包的时候,对于.jsx?文件,是采用babel去处理,babel的modules默认值是CommonJS。也就是,现在我们的代码是采用ES Module但是babel会转换成CommonJS导致的结果。
但是这里采用的是babel-loader8.x版本,其实已经不会默认转换成CommonJS了。
同时如果把后面的异步加载删除,仅仅保留Fruits,开启tree sharking后,其余page组件都会被移除,说明tree sharking并没有失效。
但是依旧被打包进了mian.js,说明tree sharking不能处理这种情况需要采用其他方法。
解决办法也比较直接:
- 在相应目录下,去引入组件
- 使用sideEffects去除代码
直接引入
import Fruit from '../pages/fruit/index';
sideEffects
在webpack4中可以通过package.json添加sideEffects来标记无副作用的文件,那么webpack就会删除该文件中未使用的代码。
sideEffects和tree sharking不太一样,tree sharking只能移除没有用到的代码成员,而sideEffects可以完整移除整个模块。
想要使用sideEffects需要在optimization里开启sideEffects: true,当然,production模式下,默认开启。
{
"sideEffects": [
"./src/client/pages/index.js"
]
}
那么以上两种方法,都可以让webpack将异步组件,分成单独的一个包。
可以看到,在切换路由的时候,会异步加载js文件。
但是如果直接访问对应的路由,服务端并没有返回相应组件,而是loading状态,说明在服务端并没有获取到组件。
因为服务端采用的路由配置是和客户端一样的,但是服务端并不需要异步加载,而是需要直接返回组件。那么需要对路由配置进行处理。
服务端处理动态加载问题
我们需要对路由进行处理,目前路由配置的component是采用异步加载,那么我们需要获取到静态路由配置。而前面,我们将动态加载函数放入到组件的静态方法里,在服务端我们就可以通过调用组件的静态方法,来获取异步加载的组件。
export const getStaticRoute = async (asyncRoute) => {
const staitcRoute = [];
const len = asyncRoute.length;
for (let i = 0; i < len; i++) {
const item = asyncRoute[i];
staitcRoute.push({
...item
});
if (item.component._load) {
const component = (await item.component._load()).default;
staitcRoute[staitcRoute.length - 1].component = component;
}
};
return staitcRoute;
};
在ssr中间件里,我们需要缓存这个获取静态路由的结果。在运行时,通过自执行函数, 将静态路由结果保存到变量中。
let staticRoute = [];
(async () => {
staticRoute = await getStaticRoute(routeConfig);
})();
并修改前面的App组件,将路由数组作为它的参数之一获取。那么在客户端获取到的是含异步组件的路由配置,在服务端则是静态路由配置。
const App = ({ pathname, initialData, routeConfig }) => {
return (
<Fragment>
<Header />
<Switch>
{
routeConfig.map(v => {
const { name, ...rest } = v;
if (pathname === v.path) {
const { component: Component, ..._rest } = rest;
return <Route key={v.path} {..._rest} render={(props) => {
props.initialData = initialData;
return <Component {...props} />
}} />
} else {
return <Route key={v.path} {...rest} />
}
})
}
<Route component={() => <Redirect to="/" />} />
</Switch>
</Fragment>
)
};
那么现在直接访问相应路由,ssr会返回直接返回组件结果。
但是在浏览器接手后,客户端渲染覆盖了服务端渲染结果。
这是因为,虽然服务端ssr返回了Banana组件渲染结果。但是浏览器并没有请求组件分割得到的xx.js。loading也就是在加载xx.js的过程。
那么解决办法就是在客户端渲染前,先进行js文件请求,在js文件请求回调中,再去进行渲染。同时去修改路由配置数组,将component设置成对应组件,那么就不会再去异步加载啦。
const clientRender = () => {
ReactDOM.hydrate(
<BrowserRouter>
<App pathname={pathname} initialData={initialData} routeConfig={routeConfig} />
</BrowserRouter>,
document.getElementById('root')
);
};
const judgeAsync = () => {
const route = getAimComp(routeConfig, pathname);
const asyncLoad = route.component._load;
if (asyncLoad) {
asyncLoad().then(res => {
route.component = res.default; // 修改路由配置数组
clientRender();
});
} else {
clientRender();
}
};
judgeAsync();
那么基于路由进行动态加载就完成辽,完整代码如下。
其他章节: